outlook-cli 1.2.0

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/cli.js ADDED
@@ -0,0 +1,1349 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Production-oriented local CLI for outlook-cli.
4
+ *
5
+ * This CLI is designed for both human operators and automation/AI agents:
6
+ * - human friendly command groups (email/calendar/folder/rule/auth)
7
+ * - machine friendly JSON output mode
8
+ * - tokenless login helper that can start auth server and open browser
9
+ */
10
+ const fs = require('fs');
11
+ const http = require('http');
12
+ const https = require('https');
13
+ const path = require('path');
14
+ const { spawn, spawnSync } = require('child_process');
15
+ const chalk = require('chalk');
16
+
17
+ const pkg = require('./package.json');
18
+ const config = require('./config');
19
+ const tokenManager = require('./auth/token-manager');
20
+ const { listTools, getTool, invokeTool } = require('./tool-registry');
21
+
22
+ const CLI_BIN_NAME = 'outlook-cli';
23
+ const SPINNER_FRAMES = ['|', '/', '-', '\\'];
24
+ const UI_STATE = {
25
+ plain: false,
26
+ color: false,
27
+ animate: false
28
+ };
29
+
30
+ function cliCommand(pathTail = '') {
31
+ return pathTail ? `${CLI_BIN_NAME} ${pathTail}` : CLI_BIN_NAME;
32
+ }
33
+
34
+ class CliError extends Error {
35
+ constructor(message, exitCode = 1) {
36
+ super(message);
37
+ this.name = 'CliError';
38
+ this.exitCode = exitCode;
39
+ }
40
+ }
41
+
42
+ class UsageError extends CliError {
43
+ constructor(message) {
44
+ super(message, 2);
45
+ this.name = 'UsageError';
46
+ }
47
+ }
48
+
49
+ function toCamelCase(value) {
50
+ return value.replace(/-([a-zA-Z])/g, (_, letter) => letter.toUpperCase());
51
+ }
52
+
53
+ function looksLikeOption(token) {
54
+ return /^--?[a-zA-Z]/.test(token);
55
+ }
56
+
57
+ function isNumericLiteral(value) {
58
+ return /^-?\d+(\.\d+)?$/.test(value);
59
+ }
60
+
61
+ function coerceValue(value) {
62
+ if (value === 'true') return true;
63
+ if (value === 'false') return false;
64
+ if (isNumericLiteral(value)) return Number(value);
65
+
66
+ if ((value.startsWith('{') && value.endsWith('}')) || (value.startsWith('[') && value.endsWith(']'))) {
67
+ try {
68
+ return JSON.parse(value);
69
+ } catch (_error) {
70
+ return value;
71
+ }
72
+ }
73
+
74
+ return value;
75
+ }
76
+
77
+ function pushOptionValue(options, key, value) {
78
+ const normalizedKey = toCamelCase(key);
79
+
80
+ if (!(normalizedKey in options)) {
81
+ options[normalizedKey] = value;
82
+ return;
83
+ }
84
+
85
+ if (!Array.isArray(options[normalizedKey])) {
86
+ options[normalizedKey] = [options[normalizedKey]];
87
+ }
88
+
89
+ options[normalizedKey].push(value);
90
+ }
91
+
92
+ function parseArgv(argv) {
93
+ const options = {};
94
+ const positional = [];
95
+
96
+ for (let i = 0; i < argv.length; i += 1) {
97
+ const token = argv[i];
98
+
99
+ if (token === '-commands' || token === '--commands') {
100
+ positional.push('commands');
101
+ continue;
102
+ }
103
+
104
+ if (token === '--') {
105
+ positional.push(...argv.slice(i + 1));
106
+ break;
107
+ }
108
+
109
+ if (!token.startsWith('-') || token === '-') {
110
+ positional.push(token);
111
+ continue;
112
+ }
113
+
114
+ if (token.startsWith('--')) {
115
+ const long = token.slice(2);
116
+
117
+ if (long.includes('=')) {
118
+ const [rawKey, ...rest] = long.split('=');
119
+ pushOptionValue(options, rawKey, coerceValue(rest.join('=')));
120
+ continue;
121
+ }
122
+
123
+ if (long.startsWith('no-')) {
124
+ pushOptionValue(options, long.slice(3), false);
125
+ continue;
126
+ }
127
+
128
+ const next = argv[i + 1];
129
+ if (next !== undefined && !looksLikeOption(next)) {
130
+ pushOptionValue(options, long, coerceValue(next));
131
+ i += 1;
132
+ } else {
133
+ pushOptionValue(options, long, true);
134
+ }
135
+
136
+ continue;
137
+ }
138
+
139
+ const shorts = token.slice(1).split('');
140
+ shorts.forEach((shortFlag) => {
141
+ pushOptionValue(options, shortFlag, true);
142
+ });
143
+ }
144
+
145
+ return { options, positional };
146
+ }
147
+
148
+ function readOption(options, key, defaultValue) {
149
+ if (key in options) {
150
+ return options[key];
151
+ }
152
+
153
+ return defaultValue;
154
+ }
155
+
156
+ function requireOption(options, key, helpText) {
157
+ const value = readOption(options, key);
158
+ if (value === undefined || value === null || value === '') {
159
+ throw new UsageError(helpText || `Missing required option --${key}`);
160
+ }
161
+
162
+ return value;
163
+ }
164
+
165
+ function asBoolean(value, defaultValue = false) {
166
+ if (value === undefined) return defaultValue;
167
+ if (typeof value === 'boolean') return value;
168
+ if (typeof value === 'number') return value !== 0;
169
+
170
+ const normalized = String(value).trim().toLowerCase();
171
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
172
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
173
+
174
+ return defaultValue;
175
+ }
176
+
177
+ function asNumber(value, defaultValue, label) {
178
+ if (value === undefined || value === null || value === '') {
179
+ return defaultValue;
180
+ }
181
+
182
+ const parsed = Number(value);
183
+ if (!Number.isFinite(parsed)) {
184
+ throw new UsageError(`Expected ${label || 'a number'}, received: ${value}`);
185
+ }
186
+
187
+ return parsed;
188
+ }
189
+
190
+ function asCsv(value) {
191
+ if (value === undefined || value === null || value === '') {
192
+ return [];
193
+ }
194
+
195
+ if (Array.isArray(value)) {
196
+ return value
197
+ .flatMap((item) => String(item).split(','))
198
+ .map((item) => item.trim())
199
+ .filter(Boolean);
200
+ }
201
+
202
+ return String(value)
203
+ .split(',')
204
+ .map((item) => item.trim())
205
+ .filter(Boolean);
206
+ }
207
+
208
+ function setUiState(options, outputMode) {
209
+ const plain = asBoolean(readOption(options, 'plain', false), false);
210
+ const colorOption = readOption(options, 'color');
211
+ const animateOption = readOption(options, 'animate');
212
+ const isTextMode = outputMode === 'text';
213
+ const isInteractive = isTextMode && process.stdout.isTTY;
214
+
215
+ UI_STATE.plain = plain;
216
+ UI_STATE.color = isInteractive && !plain && colorOption !== false;
217
+ UI_STATE.animate = isInteractive && !plain && animateOption !== false;
218
+
219
+ chalk.level = UI_STATE.color ? Math.max(chalk.level, 1) : 0;
220
+ }
221
+
222
+ function tone(text, kind) {
223
+ if (!UI_STATE.color) {
224
+ return text;
225
+ }
226
+
227
+ switch (kind) {
228
+ case 'title':
229
+ return chalk.bold.cyan(text);
230
+ case 'section':
231
+ return chalk.bold.blue(text);
232
+ case 'ok':
233
+ return chalk.green(text);
234
+ case 'warn':
235
+ return chalk.yellow(text);
236
+ case 'err':
237
+ return chalk.red(text);
238
+ case 'muted':
239
+ return chalk.gray(text);
240
+ case 'accent':
241
+ return chalk.magenta(text);
242
+ default:
243
+ return text;
244
+ }
245
+ }
246
+
247
+ function badge(kind) {
248
+ switch (kind) {
249
+ case 'ok':
250
+ return tone('[OK]', 'ok');
251
+ case 'warn':
252
+ return tone('[WARN]', 'warn');
253
+ case 'err':
254
+ return tone('[ERR]', 'err');
255
+ default:
256
+ return tone('[INFO]', 'accent');
257
+ }
258
+ }
259
+
260
+ function createSpinner(text, outputMode) {
261
+ const enabled = outputMode === 'text' && UI_STATE.animate;
262
+ let currentText = text;
263
+ let frameIndex = 0;
264
+ let timer = null;
265
+
266
+ function render() {
267
+ if (!enabled) {
268
+ return;
269
+ }
270
+
271
+ const frame = tone(SPINNER_FRAMES[frameIndex], 'accent');
272
+ process.stdout.write(`\r${frame} ${currentText}\x1b[K`);
273
+ frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
274
+ }
275
+
276
+ return {
277
+ start() {
278
+ if (!enabled) {
279
+ return this;
280
+ }
281
+
282
+ render();
283
+ timer = setInterval(render, 90);
284
+ return this;
285
+ },
286
+ update(nextText) {
287
+ currentText = nextText;
288
+ if (enabled) {
289
+ render();
290
+ }
291
+ return this;
292
+ },
293
+ stop() {
294
+ if (enabled && timer) {
295
+ clearInterval(timer);
296
+ timer = null;
297
+ process.stdout.write('\r\x1b[2K');
298
+ }
299
+ return this;
300
+ }
301
+ };
302
+ }
303
+
304
+ function formatResultText(result) {
305
+ if (result && Array.isArray(result.content)) {
306
+ const textChunks = result.content
307
+ .filter((entry) => entry && entry.type === 'text' && typeof entry.text === 'string')
308
+ .map((entry) => entry.text.trim())
309
+ .filter(Boolean);
310
+
311
+ if (textChunks.length > 0) {
312
+ return textChunks.join('\n\n');
313
+ }
314
+ }
315
+
316
+ return JSON.stringify(result, null, 2);
317
+ }
318
+
319
+ function printSuccess(outputMode, payload) {
320
+ if (outputMode === 'json') {
321
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
322
+ return;
323
+ }
324
+
325
+ if (typeof payload.message === 'string' && payload.message) {
326
+ process.stdout.write(`${badge('ok')} ${payload.message}\n`);
327
+ }
328
+
329
+ if (payload.result) {
330
+ process.stdout.write(`${tone('Result:', 'section')}\n${formatResultText(payload.result)}\n`);
331
+ }
332
+
333
+ if (payload.data && !payload.result) {
334
+ process.stdout.write(`${tone('Details:', 'section')}\n${JSON.stringify(payload.data, null, 2)}\n`);
335
+ }
336
+ }
337
+
338
+ function printError(outputMode, error) {
339
+ if (outputMode === 'json') {
340
+ process.stderr.write(`${JSON.stringify({
341
+ ok: false,
342
+ error: {
343
+ name: error.name,
344
+ message: error.message,
345
+ exitCode: error.exitCode || 1
346
+ }
347
+ }, null, 2)}\n`);
348
+ return;
349
+ }
350
+
351
+ process.stderr.write(`${badge('err')} ${error.message}\n`);
352
+ if (error instanceof UsageError) {
353
+ process.stderr.write(`${tone(`Run '${cliCommand('help')}' to see valid usage.`, 'muted')}\n`);
354
+ }
355
+ }
356
+
357
+ function buildOutputMode(options) {
358
+ const outputOption = readOption(options, 'output');
359
+ if (outputOption === 'json') {
360
+ return 'json';
361
+ }
362
+
363
+ if (asBoolean(readOption(options, 'json', false))) {
364
+ return 'json';
365
+ }
366
+
367
+ return 'text';
368
+ }
369
+
370
+ function parseKeyValueArgs(argOption) {
371
+ const pairs = Array.isArray(argOption) ? argOption : (argOption ? [argOption] : []);
372
+ const args = {};
373
+
374
+ pairs.forEach((pair) => {
375
+ const raw = String(pair);
376
+ const index = raw.indexOf('=');
377
+ if (index <= 0) {
378
+ throw new UsageError(`Invalid --arg value: ${pair}. Expected key=value.`);
379
+ }
380
+
381
+ const key = raw.slice(0, index).trim();
382
+ const value = raw.slice(index + 1).trim();
383
+ args[key] = coerceValue(value);
384
+ });
385
+
386
+ return args;
387
+ }
388
+
389
+ function parseArgsJson(argsJsonOption) {
390
+ if (!argsJsonOption) {
391
+ return {};
392
+ }
393
+
394
+ if (typeof argsJsonOption === 'object') {
395
+ return argsJsonOption;
396
+ }
397
+
398
+ try {
399
+ const parsed = JSON.parse(String(argsJsonOption));
400
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
401
+ throw new Error('Value must be a JSON object');
402
+ }
403
+
404
+ return parsed;
405
+ } catch (error) {
406
+ throw new UsageError(`Invalid --args-json value: ${error.message}`);
407
+ }
408
+ }
409
+
410
+ async function callTool(toolName, args, outputMode, commandLabel) {
411
+ const result = await invokeTool(toolName, args);
412
+ printSuccess(outputMode, {
413
+ ok: true,
414
+ command: commandLabel,
415
+ tool: toolName,
416
+ args,
417
+ result
418
+ });
419
+ }
420
+
421
+ function printUsage() {
422
+ const usage = [
423
+ `${tone(CLI_BIN_NAME, 'title')} ${tone(`v${pkg.version}`, 'muted')}`,
424
+ '',
425
+ tone('Global install:', 'section'),
426
+ ` npm i -g ${pkg.name}`,
427
+ '',
428
+ tone('Usage:', 'section'),
429
+ ` ${cliCommand('<command> [subcommand] [options]')}`,
430
+ '',
431
+ tone('Global options:', 'section'),
432
+ ' --json Output machine-readable JSON',
433
+ ' --output text|json Explicit output mode',
434
+ ' --plain Disable rich colors and animations',
435
+ ' --no-color Disable colors explicitly',
436
+ ' --no-animate Disable spinner animations',
437
+ ' --help Show help',
438
+ ' --version Show version',
439
+ '',
440
+ tone('Core commands:', 'section'),
441
+ ' commands Show all command groups and tools',
442
+ ' tools list',
443
+ ' tools schema <tool-name>',
444
+ ' call <tool-name> [--args-json "{...}"] [--arg key=value]',
445
+ ' auth status|url|login|logout',
446
+ ' update [--run] [--to latest|x.y.z]',
447
+ ' doctor',
448
+ '',
449
+ tone('Human-friendly command groups:', 'section'),
450
+ ' email list|search|read|send|mark-read',
451
+ ' calendar list|create|decline|cancel|delete',
452
+ ' folder list|create|move',
453
+ ' rule list|create|sequence',
454
+ '',
455
+ tone('Examples:', 'section'),
456
+ ` ${cliCommand('auth login --open --start-server --wait --timeout 180')}`,
457
+ ` ${cliCommand('email list --folder inbox --count 15')}`,
458
+ ` ${cliCommand('call search-emails --arg query=invoice --arg unreadOnly=true --json')}`,
459
+ ` ${cliCommand('tools schema send-email')}`,
460
+ ` ${cliCommand('update --run')}`
461
+ ];
462
+
463
+ process.stdout.write(`${usage.join('\n')}\n`);
464
+ }
465
+
466
+ function getAuthUrl() {
467
+ if (!config.AUTH_CONFIG.clientId) {
468
+ throw new CliError('Client ID is missing. Set OUTLOOK_CLIENT_ID or MS_CLIENT_ID in your environment.', 1);
469
+ }
470
+
471
+ return `${config.AUTH_CONFIG.authServerUrl}/auth?client_id=${config.AUTH_CONFIG.clientId}`;
472
+ }
473
+
474
+ function buildCommandCatalog() {
475
+ const toolCatalog = listTools();
476
+
477
+ return {
478
+ commandGroups: {
479
+ auth: ['status', 'url', 'login', 'logout'],
480
+ email: ['list', 'search', 'read', 'send', 'mark-read'],
481
+ calendar: ['list', 'create', 'decline', 'cancel', 'delete'],
482
+ folder: ['list', 'create', 'move'],
483
+ rule: ['list', 'create', 'sequence'],
484
+ tools: ['list', 'schema'],
485
+ generic: ['call'],
486
+ system: ['doctor', 'update', 'version', 'help']
487
+ },
488
+ tools: toolCatalog
489
+ };
490
+ }
491
+
492
+ function printCommandCatalog(outputMode) {
493
+ const catalog = buildCommandCatalog();
494
+
495
+ printSuccess(outputMode, {
496
+ ok: true,
497
+ command: 'commands',
498
+ data: catalog,
499
+ message: outputMode === 'text'
500
+ ? [
501
+ `Command groups:`,
502
+ `- auth: ${catalog.commandGroups.auth.join(', ')}`,
503
+ `- email: ${catalog.commandGroups.email.join(', ')}`,
504
+ `- calendar: ${catalog.commandGroups.calendar.join(', ')}`,
505
+ `- folder: ${catalog.commandGroups.folder.join(', ')}`,
506
+ `- rule: ${catalog.commandGroups.rule.join(', ')}`,
507
+ `- tools: ${catalog.commandGroups.tools.join(', ')}`,
508
+ `- generic: ${catalog.commandGroups.generic.join(', ')}`,
509
+ `- system: ${catalog.commandGroups.system.join(', ')}`,
510
+ '',
511
+ `Available MCP tools (${catalog.tools.length}):`,
512
+ ...catalog.tools.map((tool) => `- ${tool.name}`)
513
+ ].join('\n')
514
+ : undefined
515
+ });
516
+ }
517
+
518
+ function parseBaseUrl(baseUrl) {
519
+ try {
520
+ return new URL(baseUrl);
521
+ } catch (_error) {
522
+ throw new CliError(`Invalid auth server URL: ${baseUrl}`, 1);
523
+ }
524
+ }
525
+
526
+ function httpProbe(baseUrl, timeoutMs = 1500) {
527
+ return new Promise((resolve) => {
528
+ const parsed = parseBaseUrl(baseUrl);
529
+ const transport = parsed.protocol === 'https:' ? https : http;
530
+
531
+ const req = transport.request(
532
+ {
533
+ hostname: parsed.hostname,
534
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
535
+ path: '/',
536
+ method: 'GET',
537
+ timeout: timeoutMs
538
+ },
539
+ (res) => {
540
+ res.resume();
541
+ resolve(true);
542
+ }
543
+ );
544
+
545
+ req.on('error', () => resolve(false));
546
+ req.on('timeout', () => {
547
+ req.destroy();
548
+ resolve(false);
549
+ });
550
+
551
+ req.end();
552
+ });
553
+ }
554
+
555
+ async function waitForProbe(baseUrl, timeoutMs, onTick) {
556
+ const start = Date.now();
557
+ let attempts = 0;
558
+
559
+ while ((Date.now() - start) < timeoutMs) {
560
+ attempts += 1;
561
+ if (onTick) {
562
+ onTick({
563
+ attempts,
564
+ elapsedMs: Date.now() - start,
565
+ remainingMs: Math.max(0, timeoutMs - (Date.now() - start))
566
+ });
567
+ }
568
+
569
+ // eslint-disable-next-line no-await-in-loop
570
+ if (await httpProbe(baseUrl)) {
571
+ return true;
572
+ }
573
+
574
+ // eslint-disable-next-line no-await-in-loop
575
+ await new Promise((resolve) => setTimeout(resolve, 500));
576
+ }
577
+
578
+ return false;
579
+ }
580
+
581
+ function openInBrowser(url) {
582
+ const platform = process.platform;
583
+
584
+ if (platform === 'win32') {
585
+ spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
586
+ return;
587
+ }
588
+
589
+ if (platform === 'darwin') {
590
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
591
+ return;
592
+ }
593
+
594
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
595
+ }
596
+
597
+ function startAuthServer(outputMode) {
598
+ const scriptPath = path.join(__dirname, 'outlook-auth-server.js');
599
+
600
+ if (!fs.existsSync(scriptPath)) {
601
+ throw new CliError(`Auth server script not found: ${scriptPath}`, 1);
602
+ }
603
+
604
+ const child = spawn(process.execPath, [scriptPath], {
605
+ cwd: __dirname,
606
+ windowsHide: true,
607
+ stdio: 'ignore',
608
+ env: process.env
609
+ });
610
+
611
+ child.on('error', (error) => {
612
+ if (outputMode === 'text') {
613
+ process.stderr.write(`Auth server failed to start: ${error.message}\n`);
614
+ }
615
+ });
616
+
617
+ return child;
618
+ }
619
+
620
+ async function waitForValidToken(timeoutMs, onTick) {
621
+ const start = Date.now();
622
+ let attempts = 0;
623
+
624
+ while ((Date.now() - start) < timeoutMs) {
625
+ attempts += 1;
626
+ if (onTick) {
627
+ onTick({
628
+ attempts,
629
+ elapsedMs: Date.now() - start,
630
+ remainingMs: Math.max(0, timeoutMs - (Date.now() - start))
631
+ });
632
+ }
633
+
634
+ // eslint-disable-next-line no-await-in-loop
635
+ const accessToken = await tokenManager.getValidAccessToken();
636
+ if (accessToken) {
637
+ return true;
638
+ }
639
+
640
+ // eslint-disable-next-line no-await-in-loop
641
+ await new Promise((resolve) => setTimeout(resolve, 1500));
642
+ }
643
+
644
+ return false;
645
+ }
646
+
647
+ async function handleAuthCommand(action, options, outputMode) {
648
+ switch (action) {
649
+ case 'status': {
650
+ const accessToken = await tokenManager.getValidAccessToken();
651
+ const tokens = tokenManager.loadTokenCache();
652
+
653
+ printSuccess(outputMode, {
654
+ ok: true,
655
+ command: 'auth status',
656
+ data: {
657
+ authenticated: Boolean(accessToken),
658
+ tokenPath: config.AUTH_CONFIG.tokenStorePath,
659
+ expiresAt: tokens && tokens.expires_at ? new Date(tokens.expires_at).toISOString() : null,
660
+ authServerUrl: config.AUTH_CONFIG.authServerUrl
661
+ },
662
+ message: accessToken ? 'Authenticated and ready.' : 'Not authenticated.'
663
+ });
664
+ return;
665
+ }
666
+
667
+ case 'url': {
668
+ const authUrl = getAuthUrl();
669
+ printSuccess(outputMode, {
670
+ ok: true,
671
+ command: 'auth url',
672
+ data: { authUrl },
673
+ message: authUrl
674
+ });
675
+ return;
676
+ }
677
+
678
+ case 'logout': {
679
+ const removed = tokenManager.clearTokenCache();
680
+ printSuccess(outputMode, {
681
+ ok: true,
682
+ command: 'auth logout',
683
+ data: { removed, tokenPath: config.AUTH_CONFIG.tokenStorePath },
684
+ message: removed ? 'Token cache cleared.' : 'Unable to clear token cache.'
685
+ });
686
+ return;
687
+ }
688
+
689
+ case 'login': {
690
+ const force = asBoolean(readOption(options, 'force', false));
691
+ const shouldOpen = asBoolean(readOption(options, 'open', true), true);
692
+ const shouldStartServer = asBoolean(readOption(options, 'startServer', true), true);
693
+ const shouldWait = asBoolean(readOption(options, 'wait', true), true);
694
+ const timeoutSeconds = asNumber(readOption(options, 'timeout', 180), 180, 'timeout seconds');
695
+ const timeoutMs = Math.max(5, timeoutSeconds) * 1000;
696
+
697
+ if (force) {
698
+ tokenManager.clearTokenCache();
699
+ }
700
+
701
+ const existingToken = await tokenManager.getValidAccessToken();
702
+ if (existingToken && !force) {
703
+ printSuccess(outputMode, {
704
+ ok: true,
705
+ command: 'auth login',
706
+ data: { alreadyAuthenticated: true },
707
+ message: 'Already authenticated. Use --force to re-authenticate.'
708
+ });
709
+ return;
710
+ }
711
+
712
+ const authUrl = getAuthUrl();
713
+ let startedServer = null;
714
+ let startSpinner = null;
715
+ let waitSpinner = null;
716
+
717
+ try {
718
+ const runningBefore = await httpProbe(config.AUTH_CONFIG.authServerUrl);
719
+ if (!runningBefore && shouldStartServer) {
720
+ startSpinner = createSpinner('Starting auth server...', outputMode).start();
721
+ startedServer = startAuthServer(outputMode);
722
+ const ready = await waitForProbe(
723
+ config.AUTH_CONFIG.authServerUrl,
724
+ 10000,
725
+ ({ remainingMs }) => {
726
+ if (startSpinner) {
727
+ startSpinner.update(`Starting auth server... ${Math.ceil(remainingMs / 1000)}s`);
728
+ }
729
+ }
730
+ );
731
+ if (startSpinner) {
732
+ startSpinner.stop();
733
+ startSpinner = null;
734
+ }
735
+
736
+ if (!ready) {
737
+ throw new CliError('Auth server did not become ready on time.', 1);
738
+ }
739
+
740
+ if (outputMode === 'text') {
741
+ process.stdout.write(`${badge('ok')} Auth server is ready.\n`);
742
+ }
743
+ }
744
+
745
+ if (!runningBefore && !shouldStartServer) {
746
+ throw new CliError('Auth server is not running. Start it with `npm run auth-server` or use --start-server.', 1);
747
+ }
748
+
749
+ if (shouldOpen) {
750
+ openInBrowser(authUrl);
751
+ }
752
+
753
+ if (!shouldWait) {
754
+ printSuccess(outputMode, {
755
+ ok: true,
756
+ command: 'auth login',
757
+ data: {
758
+ authUrl,
759
+ waiting: false,
760
+ serverStarted: Boolean(startedServer)
761
+ },
762
+ message: `Open this URL to authenticate: ${authUrl}`
763
+ });
764
+ return;
765
+ }
766
+
767
+ if (outputMode === 'text') {
768
+ process.stdout.write(`${badge('info')} Authenticate in browser: ${authUrl}\n`);
769
+ }
770
+
771
+ waitSpinner = createSpinner(`Waiting for token... ${Math.ceil(timeoutMs / 1000)}s left`, outputMode).start();
772
+ const authenticated = await waitForValidToken(
773
+ timeoutMs,
774
+ ({ remainingMs }) => {
775
+ if (waitSpinner) {
776
+ waitSpinner.update(`Waiting for token... ${Math.ceil(remainingMs / 1000)}s left`);
777
+ }
778
+ }
779
+ );
780
+ if (waitSpinner) {
781
+ waitSpinner.stop();
782
+ waitSpinner = null;
783
+ }
784
+
785
+ if (!authenticated) {
786
+ throw new CliError('Timed out waiting for authentication completion.', 1);
787
+ }
788
+
789
+ printSuccess(outputMode, {
790
+ ok: true,
791
+ command: 'auth login',
792
+ data: {
793
+ authenticated: true,
794
+ authUrl,
795
+ tokenPath: config.AUTH_CONFIG.tokenStorePath,
796
+ serverStarted: Boolean(startedServer)
797
+ },
798
+ message: 'Authentication completed successfully.'
799
+ });
800
+ } finally {
801
+ if (startSpinner) {
802
+ startSpinner.stop();
803
+ }
804
+ if (waitSpinner) {
805
+ waitSpinner.stop();
806
+ }
807
+ if (startedServer && !startedServer.killed) {
808
+ startedServer.kill('SIGTERM');
809
+ }
810
+ }
811
+
812
+ return;
813
+ }
814
+
815
+ default:
816
+ throw new UsageError('Unknown auth command. Use: auth status|url|login|logout');
817
+ }
818
+ }
819
+
820
+ function validateToolExists(toolName) {
821
+ if (!getTool(toolName)) {
822
+ throw new UsageError(`Unknown tool: ${toolName}. Use '${cliCommand('tools list')}' to view available tools.`);
823
+ }
824
+ }
825
+
826
+ async function handleToolsCommand(action, positional, outputMode) {
827
+ if (action === 'list' || !action) {
828
+ const tools = listTools();
829
+
830
+ printSuccess(outputMode, {
831
+ ok: true,
832
+ command: 'tools list',
833
+ data: { count: tools.length, tools },
834
+ message: outputMode === 'text'
835
+ ? tools.map((tool) => `- ${tool.name}: ${tool.description}`).join('\n')
836
+ : undefined
837
+ });
838
+ return;
839
+ }
840
+
841
+ if (action === 'schema') {
842
+ const toolName = positional[0];
843
+ if (!toolName) {
844
+ throw new UsageError(`Usage: ${cliCommand('tools schema <tool-name>')}`);
845
+ }
846
+
847
+ const tool = getTool(toolName);
848
+ if (!tool) {
849
+ throw new UsageError(`Unknown tool: ${toolName}`);
850
+ }
851
+
852
+ printSuccess(outputMode, {
853
+ ok: true,
854
+ command: 'tools schema',
855
+ data: {
856
+ name: tool.name,
857
+ description: tool.description,
858
+ inputSchema: tool.inputSchema
859
+ },
860
+ message: outputMode === 'text'
861
+ ? `${tool.name}\n${tool.description}\n${JSON.stringify(tool.inputSchema, null, 2)}`
862
+ : undefined
863
+ });
864
+ return;
865
+ }
866
+
867
+ throw new UsageError('Unknown tools command. Use: tools list|schema <tool-name>');
868
+ }
869
+
870
+ async function handleGenericCall(positional, options, outputMode) {
871
+ const toolName = positional[0];
872
+ if (!toolName) {
873
+ throw new UsageError(`Usage: ${cliCommand('call <tool-name> [--args-json "{...}"] [--arg key=value]')}`);
874
+ }
875
+
876
+ validateToolExists(toolName);
877
+
878
+ const fromJson = parseArgsJson(readOption(options, 'argsJson'));
879
+ const fromPairs = parseKeyValueArgs(readOption(options, 'arg'));
880
+
881
+ const args = {
882
+ ...fromJson,
883
+ ...fromPairs
884
+ };
885
+
886
+ await callTool(toolName, args, outputMode, `call ${toolName}`);
887
+ }
888
+
889
+ function buildEventRange(value, timezone) {
890
+ if (!timezone) {
891
+ return value;
892
+ }
893
+
894
+ return {
895
+ dateTime: value,
896
+ timeZone: timezone
897
+ };
898
+ }
899
+
900
+ async function handleEmailCommand(action, options, outputMode) {
901
+ switch (action) {
902
+ case 'list': {
903
+ await callTool(
904
+ 'list-emails',
905
+ {
906
+ folder: readOption(options, 'folder', 'inbox'),
907
+ count: asNumber(readOption(options, 'count', 10), 10, 'count')
908
+ },
909
+ outputMode,
910
+ 'email list'
911
+ );
912
+ return;
913
+ }
914
+
915
+ case 'search': {
916
+ await callTool(
917
+ 'search-emails',
918
+ {
919
+ query: readOption(options, 'query', ''),
920
+ folder: readOption(options, 'folder', 'inbox'),
921
+ from: readOption(options, 'from', ''),
922
+ to: readOption(options, 'to', ''),
923
+ subject: readOption(options, 'subject', ''),
924
+ hasAttachments: readOption(options, 'hasAttachments'),
925
+ unreadOnly: readOption(options, 'unreadOnly'),
926
+ count: asNumber(readOption(options, 'count', 10), 10, 'count')
927
+ },
928
+ outputMode,
929
+ 'email search'
930
+ );
931
+ return;
932
+ }
933
+
934
+ case 'read': {
935
+ await callTool(
936
+ 'read-email',
937
+ { id: requireOption(options, 'id', `Usage: ${cliCommand('email read --id <email-id>')}`) },
938
+ outputMode,
939
+ 'email read'
940
+ );
941
+ return;
942
+ }
943
+
944
+ case 'send': {
945
+ await callTool(
946
+ 'send-email',
947
+ {
948
+ to: requireOption(options, 'to', `Usage: ${cliCommand('email send --to <emails> --subject <subject> --body <body>')}`),
949
+ cc: readOption(options, 'cc'),
950
+ bcc: readOption(options, 'bcc'),
951
+ subject: requireOption(options, 'subject', `Usage: ${cliCommand('email send --to <emails> --subject <subject> --body <body>')}`),
952
+ body: requireOption(options, 'body', `Usage: ${cliCommand('email send --to <emails> --subject <subject> --body <body>')}`),
953
+ importance: readOption(options, 'importance', 'normal'),
954
+ saveToSentItems: asBoolean(readOption(options, 'saveToSentItems', true), true)
955
+ },
956
+ outputMode,
957
+ 'email send'
958
+ );
959
+ return;
960
+ }
961
+
962
+ case 'mark-read': {
963
+ await callTool(
964
+ 'mark-as-read',
965
+ {
966
+ id: requireOption(options, 'id', `Usage: ${cliCommand('email mark-read --id <email-id> [--is-read true|false]')}`),
967
+ isRead: asBoolean(readOption(options, 'isRead', true), true)
968
+ },
969
+ outputMode,
970
+ 'email mark-read'
971
+ );
972
+ return;
973
+ }
974
+
975
+ default:
976
+ throw new UsageError('Unknown email command. Use: email list|search|read|send|mark-read');
977
+ }
978
+ }
979
+
980
+ async function handleCalendarCommand(action, options, outputMode) {
981
+ switch (action) {
982
+ case 'list': {
983
+ await callTool(
984
+ 'list-events',
985
+ {
986
+ count: asNumber(readOption(options, 'count', 10), 10, 'count')
987
+ },
988
+ outputMode,
989
+ 'calendar list'
990
+ );
991
+ return;
992
+ }
993
+
994
+ case 'create': {
995
+ const timezone = readOption(options, 'timezone');
996
+ const start = requireOption(options, 'start', `Usage: ${cliCommand('calendar create --subject <subject> --start <iso> --end <iso> [--timezone <tz>]')}`);
997
+ const end = requireOption(options, 'end', `Usage: ${cliCommand('calendar create --subject <subject> --start <iso> --end <iso> [--timezone <tz>]')}`);
998
+
999
+ await callTool(
1000
+ 'create-event',
1001
+ {
1002
+ subject: requireOption(options, 'subject', `Usage: ${cliCommand('calendar create --subject <subject> --start <iso> --end <iso>')}`),
1003
+ start: buildEventRange(start, timezone),
1004
+ end: buildEventRange(end, timezone),
1005
+ attendees: asCsv(readOption(options, 'attendees')),
1006
+ body: readOption(options, 'body', '')
1007
+ },
1008
+ outputMode,
1009
+ 'calendar create'
1010
+ );
1011
+ return;
1012
+ }
1013
+
1014
+ case 'decline': {
1015
+ await callTool(
1016
+ 'decline-event',
1017
+ {
1018
+ eventId: requireOption(options, 'eventId', `Usage: ${cliCommand('calendar decline --event-id <id> [--comment <text>]')}`),
1019
+ comment: readOption(options, 'comment')
1020
+ },
1021
+ outputMode,
1022
+ 'calendar decline'
1023
+ );
1024
+ return;
1025
+ }
1026
+
1027
+ case 'cancel': {
1028
+ await callTool(
1029
+ 'cancel-event',
1030
+ {
1031
+ eventId: requireOption(options, 'eventId', `Usage: ${cliCommand('calendar cancel --event-id <id> [--comment <text>]')}`),
1032
+ comment: readOption(options, 'comment')
1033
+ },
1034
+ outputMode,
1035
+ 'calendar cancel'
1036
+ );
1037
+ return;
1038
+ }
1039
+
1040
+ case 'delete': {
1041
+ await callTool(
1042
+ 'delete-event',
1043
+ {
1044
+ eventId: requireOption(options, 'eventId', `Usage: ${cliCommand('calendar delete --event-id <id>')}`)
1045
+ },
1046
+ outputMode,
1047
+ 'calendar delete'
1048
+ );
1049
+ return;
1050
+ }
1051
+
1052
+ default:
1053
+ throw new UsageError('Unknown calendar command. Use: calendar list|create|decline|cancel|delete');
1054
+ }
1055
+ }
1056
+
1057
+ async function handleFolderCommand(action, options, outputMode) {
1058
+ switch (action) {
1059
+ case 'list': {
1060
+ await callTool(
1061
+ 'list-folders',
1062
+ {
1063
+ includeItemCounts: asBoolean(readOption(options, 'includeItemCounts', false), false),
1064
+ includeChildren: asBoolean(readOption(options, 'includeChildren', false), false)
1065
+ },
1066
+ outputMode,
1067
+ 'folder list'
1068
+ );
1069
+ return;
1070
+ }
1071
+
1072
+ case 'create': {
1073
+ await callTool(
1074
+ 'create-folder',
1075
+ {
1076
+ name: requireOption(options, 'name', `Usage: ${cliCommand('folder create --name <folder-name> [--parent-folder <name>]')}`),
1077
+ parentFolder: readOption(options, 'parentFolder', '')
1078
+ },
1079
+ outputMode,
1080
+ 'folder create'
1081
+ );
1082
+ return;
1083
+ }
1084
+
1085
+ case 'move': {
1086
+ await callTool(
1087
+ 'move-emails',
1088
+ {
1089
+ emailIds: requireOption(options, 'emailIds', `Usage: ${cliCommand('folder move --email-ids <id1,id2> --target-folder <name> [--source-folder <name>]')}`),
1090
+ targetFolder: requireOption(options, 'targetFolder', `Usage: ${cliCommand('folder move --email-ids <id1,id2> --target-folder <name> [--source-folder <name>]')}`),
1091
+ sourceFolder: readOption(options, 'sourceFolder', '')
1092
+ },
1093
+ outputMode,
1094
+ 'folder move'
1095
+ );
1096
+ return;
1097
+ }
1098
+
1099
+ default:
1100
+ throw new UsageError('Unknown folder command. Use: folder list|create|move');
1101
+ }
1102
+ }
1103
+
1104
+ async function handleRuleCommand(action, options, outputMode) {
1105
+ switch (action) {
1106
+ case 'list': {
1107
+ await callTool(
1108
+ 'list-rules',
1109
+ {
1110
+ includeDetails: asBoolean(readOption(options, 'includeDetails', false), false)
1111
+ },
1112
+ outputMode,
1113
+ 'rule list'
1114
+ );
1115
+ return;
1116
+ }
1117
+
1118
+ case 'create': {
1119
+ await callTool(
1120
+ 'create-rule',
1121
+ {
1122
+ name: requireOption(options, 'name', `Usage: ${cliCommand('rule create --name <name> [rule options]')}`),
1123
+ fromAddresses: readOption(options, 'fromAddresses'),
1124
+ containsSubject: readOption(options, 'containsSubject'),
1125
+ hasAttachments: readOption(options, 'hasAttachments'),
1126
+ moveToFolder: readOption(options, 'moveToFolder'),
1127
+ markAsRead: readOption(options, 'markAsRead'),
1128
+ isEnabled: asBoolean(readOption(options, 'isEnabled', true), true),
1129
+ sequence: readOption(options, 'sequence')
1130
+ },
1131
+ outputMode,
1132
+ 'rule create'
1133
+ );
1134
+ return;
1135
+ }
1136
+
1137
+ case 'sequence': {
1138
+ await callTool(
1139
+ 'edit-rule-sequence',
1140
+ {
1141
+ ruleName: requireOption(options, 'ruleName', `Usage: ${cliCommand('rule sequence --rule-name <name> --sequence <number>')}`),
1142
+ sequence: asNumber(requireOption(options, 'sequence', `Usage: ${cliCommand('rule sequence --rule-name <name> --sequence <number>')}`), 1, 'sequence')
1143
+ },
1144
+ outputMode,
1145
+ 'rule sequence'
1146
+ );
1147
+ return;
1148
+ }
1149
+
1150
+ default:
1151
+ throw new UsageError('Unknown rule command. Use: rule list|create|sequence');
1152
+ }
1153
+ }
1154
+
1155
+ async function handleDoctorCommand(outputMode) {
1156
+ const tokenPath = config.AUTH_CONFIG.tokenStorePath;
1157
+ const tokenExists = fs.existsSync(tokenPath);
1158
+ const authServerReachable = await httpProbe(config.AUTH_CONFIG.authServerUrl, 1000);
1159
+ const hasClientId = Boolean(config.AUTH_CONFIG.clientId);
1160
+ const hasClientSecret = Boolean(config.AUTH_CONFIG.clientSecret);
1161
+ const accessToken = await tokenManager.getValidAccessToken();
1162
+
1163
+ const status = {
1164
+ runtime: {
1165
+ node: process.version,
1166
+ platform: process.platform,
1167
+ cwd: process.cwd()
1168
+ },
1169
+ auth: {
1170
+ hasClientId,
1171
+ hasClientSecret,
1172
+ authenticated: Boolean(accessToken),
1173
+ tokenPath,
1174
+ tokenFileExists: tokenExists,
1175
+ authServerUrl: config.AUTH_CONFIG.authServerUrl,
1176
+ authServerReachable
1177
+ },
1178
+ mode: {
1179
+ testMode: config.USE_TEST_MODE,
1180
+ debugLogs: config.DEBUG_LOGS
1181
+ }
1182
+ };
1183
+
1184
+ const warnings = [];
1185
+ if (!hasClientId) warnings.push('Missing client ID. Set OUTLOOK_CLIENT_ID or MS_CLIENT_ID.');
1186
+ if (!hasClientSecret) warnings.push('Missing client secret. Set OUTLOOK_CLIENT_SECRET or MS_CLIENT_SECRET.');
1187
+ if (!tokenExists) warnings.push(`Token file not found at ${tokenPath}. Run '${cliCommand('auth login')}'.`);
1188
+ if (!authServerReachable) warnings.push(`Auth server is not reachable at ${config.AUTH_CONFIG.authServerUrl}.`);
1189
+
1190
+ printSuccess(outputMode, {
1191
+ ok: true,
1192
+ command: 'doctor',
1193
+ data: {
1194
+ ...status,
1195
+ warnings
1196
+ },
1197
+ message: outputMode === 'text'
1198
+ ? [
1199
+ `Node: ${status.runtime.node}`,
1200
+ `Platform: ${status.runtime.platform}`,
1201
+ `Authenticated: ${status.auth.authenticated ? 'yes' : 'no'}`,
1202
+ `Token file: ${status.auth.tokenFileExists ? 'found' : 'missing'} (${tokenPath})`,
1203
+ `Auth server reachable: ${status.auth.authServerReachable ? 'yes' : 'no'}`,
1204
+ warnings.length ? `Warnings:\n- ${warnings.join('\n- ')}` : 'No warnings detected.'
1205
+ ].join('\n')
1206
+ : undefined
1207
+ });
1208
+ }
1209
+
1210
+ async function handleUpdateCommand(options, outputMode) {
1211
+ const target = String(readOption(options, 'to', 'latest'));
1212
+ const installArg = `${pkg.name}@${target}`;
1213
+ const updateCommand = `npm i -g ${installArg}`;
1214
+ const shouldRun = asBoolean(readOption(options, 'run', false), false);
1215
+
1216
+ if (!shouldRun) {
1217
+ printSuccess(outputMode, {
1218
+ ok: true,
1219
+ command: 'update',
1220
+ data: {
1221
+ packageName: pkg.name,
1222
+ target,
1223
+ command: updateCommand
1224
+ },
1225
+ message: `Run this to update globally: ${updateCommand}`
1226
+ });
1227
+ return;
1228
+ }
1229
+
1230
+ if (outputMode === 'text') {
1231
+ process.stdout.write(`${badge('info')} Running: ${updateCommand}\n`);
1232
+ }
1233
+
1234
+ const result = spawnSync('npm', ['i', '-g', installArg], {
1235
+ shell: process.platform === 'win32',
1236
+ encoding: 'utf8',
1237
+ stdio: outputMode === 'text' ? 'inherit' : 'pipe'
1238
+ });
1239
+
1240
+ if (result.status !== 0) {
1241
+ const details = outputMode === 'json'
1242
+ ? `${result.stdout || ''}${result.stderr || ''}`.trim()
1243
+ : '';
1244
+
1245
+ throw new CliError(`Global update failed${details ? `: ${details}` : '.'}`, 1);
1246
+ }
1247
+
1248
+ printSuccess(outputMode, {
1249
+ ok: true,
1250
+ command: 'update',
1251
+ data: {
1252
+ packageName: pkg.name,
1253
+ target,
1254
+ command: updateCommand
1255
+ },
1256
+ message: `Updated successfully to ${installArg}.`
1257
+ });
1258
+ }
1259
+
1260
+ async function run() {
1261
+ const parsed = parseArgv(process.argv.slice(2));
1262
+ const { options, positional } = parsed;
1263
+ const outputMode = buildOutputMode(options);
1264
+ setUiState(options, outputMode);
1265
+
1266
+ const wantsHelp = asBoolean(readOption(options, 'help', false), false) || asBoolean(readOption(options, 'h', false), false);
1267
+ const wantsVersion = asBoolean(readOption(options, 'version', false), false) || asBoolean(readOption(options, 'v', false), false);
1268
+
1269
+ if (wantsVersion) {
1270
+ printSuccess(outputMode, {
1271
+ ok: true,
1272
+ command: 'version',
1273
+ data: { version: pkg.version },
1274
+ message: pkg.version
1275
+ });
1276
+ return;
1277
+ }
1278
+
1279
+ if (wantsHelp || positional.length === 0 || positional[0] === 'help') {
1280
+ printUsage();
1281
+ return;
1282
+ }
1283
+
1284
+ const command = positional[0];
1285
+ const action = positional[1];
1286
+ const rest = positional.slice(2);
1287
+
1288
+ switch (command) {
1289
+ case 'version':
1290
+ printSuccess(outputMode, {
1291
+ ok: true,
1292
+ command: 'version',
1293
+ data: { version: pkg.version },
1294
+ message: pkg.version
1295
+ });
1296
+ return;
1297
+
1298
+ case 'tools':
1299
+ await handleToolsCommand(action, rest, outputMode);
1300
+ return;
1301
+
1302
+ case 'commands':
1303
+ printCommandCatalog(outputMode);
1304
+ return;
1305
+
1306
+ case 'call':
1307
+ await handleGenericCall(positional.slice(1), options, outputMode);
1308
+ return;
1309
+
1310
+ case 'auth':
1311
+ await handleAuthCommand(action, options, outputMode);
1312
+ return;
1313
+
1314
+ case 'email':
1315
+ await handleEmailCommand(action, options, outputMode);
1316
+ return;
1317
+
1318
+ case 'calendar':
1319
+ await handleCalendarCommand(action, options, outputMode);
1320
+ return;
1321
+
1322
+ case 'folder':
1323
+ await handleFolderCommand(action, options, outputMode);
1324
+ return;
1325
+
1326
+ case 'rule':
1327
+ await handleRuleCommand(action, options, outputMode);
1328
+ return;
1329
+
1330
+ case 'doctor':
1331
+ await handleDoctorCommand(outputMode);
1332
+ return;
1333
+
1334
+ case 'update':
1335
+ await handleUpdateCommand(options, outputMode);
1336
+ return;
1337
+
1338
+ default:
1339
+ throw new UsageError(`Unknown command: ${command}. Run '${cliCommand('help')}' for usage.`);
1340
+ }
1341
+ }
1342
+
1343
+ run().catch((error) => {
1344
+ const parsed = parseArgv(process.argv.slice(2));
1345
+ const outputMode = buildOutputMode(parsed.options);
1346
+ setUiState(parsed.options, outputMode);
1347
+ printError(outputMode, error);
1348
+ process.exit(error.exitCode || 1);
1349
+ });