@naarang/glancebar 1.0.8 → 1.0.10

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +2224 -1808
package/src/cli.ts CHANGED
@@ -1,1808 +1,2224 @@
1
- #!/usr/bin/env bun
2
- import { google } from "googleapis";
3
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
4
- import { join, dirname } from "path";
5
- import { createServer, Server } from "http";
6
- import { createInterface } from "readline";
7
- import { fileURLToPath } from "url";
8
-
9
- // Get package version
10
- const __filename = fileURLToPath(import.meta.url);
11
- const __dirname = dirname(__filename);
12
- const packageJson = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
13
- const VERSION = packageJson.version;
14
-
15
- // ============================================================================
16
- // Configuration
17
- // ============================================================================
18
-
19
- interface Config {
20
- accounts: string[]; // Legacy - for backwards compatibility
21
- gmailAccounts: string[]; // Google accounts
22
- zohoAccounts: ZohoAccount[]; // Zoho accounts
23
- lookaheadHours: number;
24
- showCalendarName: boolean;
25
- countdownThresholdMinutes: number;
26
- maxTitleLength: number;
27
- waterReminderEnabled: boolean;
28
- stretchReminderEnabled: boolean;
29
- eyeReminderEnabled: boolean;
30
- showCpuUsage: boolean;
31
- showMemoryUsage: boolean;
32
- showZohoTasks: boolean;
33
- maxTasksToShow: number;
34
- }
35
-
36
- interface ZohoAccount {
37
- email: string;
38
- datacenter: string; // com, eu, in, com.au, com.cn, jp, zohocloud.ca
39
- }
40
-
41
- const COLORS: Record<string, string> = {
42
- reset: "\x1b[0m",
43
- red: "\x1b[31m",
44
- green: "\x1b[32m",
45
- yellow: "\x1b[33m",
46
- blue: "\x1b[34m",
47
- magenta: "\x1b[35m",
48
- cyan: "\x1b[36m",
49
- white: "\x1b[37m",
50
- brightRed: "\x1b[91m",
51
- brightGreen: "\x1b[92m",
52
- brightYellow: "\x1b[93m",
53
- brightBlue: "\x1b[94m",
54
- brightMagenta: "\x1b[95m",
55
- brightCyan: "\x1b[96m",
56
- orange: "\x1b[38;5;208m",
57
- pink: "\x1b[38;5;213m",
58
- purple: "\x1b[38;5;141m",
59
- };
60
-
61
- const ACCOUNT_COLORS = ["cyan", "magenta", "brightGreen", "orange", "brightBlue", "pink", "yellow", "purple"];
62
-
63
- const DEFAULT_CONFIG: Config = {
64
- accounts: [], // Legacy
65
- gmailAccounts: [],
66
- zohoAccounts: [],
67
- lookaheadHours: 8,
68
- showCalendarName: true,
69
- countdownThresholdMinutes: 60,
70
- maxTitleLength: 120,
71
- waterReminderEnabled: true,
72
- stretchReminderEnabled: true,
73
- eyeReminderEnabled: true,
74
- showCpuUsage: false,
75
- showMemoryUsage: false,
76
- showZohoTasks: true,
77
- maxTasksToShow: 3,
78
- };
79
-
80
- const WATER_REMINDERS = [
81
- "Stay hydrated! Drink some water",
82
- "Time for a water break!",
83
- "Hydration check! Grab some water",
84
- "Your body needs water. Drink up!",
85
- "Water break! Stay refreshed",
86
- "Don't forget to drink water!",
87
- "Hydrate yourself! Take a sip",
88
- "Quick reminder: Drink water!",
89
- ];
90
-
91
- const STRETCH_REMINDERS = [
92
- "Time to stretch! Stand up and move",
93
- "Stretch break! Roll your shoulders",
94
- "Stand up and stretch your legs",
95
- "Posture check! Sit up straight",
96
- "Take a quick stretch break",
97
- "Move your body! Quick stretch",
98
- "Stretch your neck and shoulders",
99
- "Stand up! Your body will thank you",
100
- ];
101
-
102
- const EYE_REMINDERS = [
103
- "Eye break! Look 20ft away for 20s",
104
- "Rest your eyes - look at something distant",
105
- "20-20-20: Look away from screen",
106
- "Give your eyes a break!",
107
- "Look away from the screen for a moment",
108
- "Eye rest time! Focus on something far",
109
- ];
110
-
111
- function getConfigDir(): string {
112
- const home = process.env.HOME || process.env.USERPROFILE || "";
113
- return join(home, ".glancebar");
114
- }
115
-
116
- function getConfigPath(): string {
117
- return join(getConfigDir(), "config.json");
118
- }
119
-
120
- function getTokensDir(): string {
121
- return join(getConfigDir(), "tokens");
122
- }
123
-
124
- function ensureConfigDir(): void {
125
- const dir = getConfigDir();
126
- if (!existsSync(dir)) {
127
- mkdirSync(dir, { recursive: true });
128
- }
129
- }
130
-
131
- function loadConfig(): Config {
132
- const configPath = getConfigPath();
133
- if (!existsSync(configPath)) {
134
- return { ...DEFAULT_CONFIG };
135
- }
136
-
137
- try {
138
- const content = readFileSync(configPath, "utf-8");
139
- const userConfig = JSON.parse(content);
140
- const config = { ...DEFAULT_CONFIG, ...userConfig };
141
-
142
- // Migrate legacy accounts to gmailAccounts
143
- if (config.accounts && config.accounts.length > 0 && (!config.gmailAccounts || config.gmailAccounts.length === 0)) {
144
- config.gmailAccounts = [...config.accounts];
145
- }
146
-
147
- return config;
148
- } catch {
149
- return { ...DEFAULT_CONFIG };
150
- }
151
- }
152
-
153
- function saveConfig(config: Config): void {
154
- ensureConfigDir();
155
- writeFileSync(getConfigPath(), JSON.stringify(config, null, 2));
156
- }
157
-
158
- // ============================================================================
159
- // Google OAuth Authentication
160
- // ============================================================================
161
-
162
- const GOOGLE_SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"];
163
- const REDIRECT_URI = "http://localhost:3000/callback";
164
-
165
- interface GoogleCredentials {
166
- installed?: { client_id: string; client_secret: string };
167
- web?: { client_id: string; client_secret: string };
168
- }
169
-
170
- // ============================================================================
171
- // Zoho OAuth Authentication
172
- // ============================================================================
173
-
174
- const ZOHO_SCOPES = [
175
- "ZohoCalendar.calendar.READ",
176
- "ZohoCalendar.event.READ",
177
- "ZohoMail.tasks.READ",
178
- ];
179
- const ZOHO_REDIRECT_URI = "http://localhost:3000/callback";
180
-
181
- // Zoho datacenter mappings
182
- const ZOHO_DATACENTERS: Record<string, { accounts: string; calendar: string; mail: string }> = {
183
- "com": { accounts: "https://accounts.zoho.com", calendar: "https://calendar.zoho.com", mail: "https://mail.zoho.com" },
184
- "eu": { accounts: "https://accounts.zoho.eu", calendar: "https://calendar.zoho.eu", mail: "https://mail.zoho.eu" },
185
- "in": { accounts: "https://accounts.zoho.in", calendar: "https://calendar.zoho.in", mail: "https://mail.zoho.in" },
186
- "com.au": { accounts: "https://accounts.zoho.com.au", calendar: "https://calendar.zoho.com.au", mail: "https://mail.zoho.com.au" },
187
- "com.cn": { accounts: "https://accounts.zoho.com.cn", calendar: "https://calendar.zoho.com.cn", mail: "https://mail.zoho.com.cn" },
188
- "jp": { accounts: "https://accounts.zoho.jp", calendar: "https://calendar.zoho.jp", mail: "https://mail.zoho.jp" },
189
- "zohocloud.ca": { accounts: "https://accounts.zohocloud.ca", calendar: "https://calendar.zohocloud.ca", mail: "https://mail.zohocloud.ca" },
190
- };
191
-
192
- interface ZohoCredentials {
193
- client_id: string;
194
- client_secret: string;
195
- }
196
-
197
- interface ZohoToken {
198
- access_token: string;
199
- refresh_token: string;
200
- expires_at: number;
201
- api_domain?: string;
202
- }
203
-
204
- // Google credentials
205
- function getGoogleCredentialsPath(): string {
206
- return join(getConfigDir(), "credentials.json");
207
- }
208
-
209
- function loadGoogleCredentials(): GoogleCredentials {
210
- const credPath = getGoogleCredentialsPath();
211
- if (!existsSync(credPath)) {
212
- throw new Error(
213
- `credentials.json not found at ${credPath}\n\nPlease download OAuth credentials from Google Cloud Console and save to:\n${credPath}\n\nRun 'glancebar setup' for detailed instructions.`
214
- );
215
- }
216
- return JSON.parse(readFileSync(credPath, "utf-8"));
217
- }
218
-
219
- function getGoogleTokenPath(account: string): string {
220
- const safeAccount = account.replace(/[^a-zA-Z0-9@.-]/g, "_");
221
- return join(getTokensDir(), `google_${safeAccount}.json`);
222
- }
223
-
224
- // Legacy token path (for migration)
225
- function getLegacyTokenPath(account: string): string {
226
- const safeAccount = account.replace(/[^a-zA-Z0-9@.-]/g, "_");
227
- return join(getTokensDir(), `${safeAccount}.json`);
228
- }
229
-
230
- // Zoho credentials
231
- function getZohoCredentialsPath(): string {
232
- return join(getConfigDir(), "zoho_credentials.json");
233
- }
234
-
235
- function loadZohoCredentials(): ZohoCredentials {
236
- const credPath = getZohoCredentialsPath();
237
- if (!existsSync(credPath)) {
238
- throw new Error(
239
- `zoho_credentials.json not found at ${credPath}\n\nPlease create OAuth credentials in Zoho API Console and save to:\n${credPath}\n\nRun 'glancebar setup' for detailed instructions.`
240
- );
241
- }
242
- return JSON.parse(readFileSync(credPath, "utf-8"));
243
- }
244
-
245
- function getZohoTokenPath(account: string): string {
246
- const safeAccount = account.replace(/[^a-zA-Z0-9@.-]/g, "_");
247
- return join(getTokensDir(), `zoho_${safeAccount}.json`);
248
- }
249
-
250
- function createGoogleOAuth2Client(credentials: GoogleCredentials) {
251
- const { client_id, client_secret } = credentials.installed || credentials.web!;
252
- return new google.auth.OAuth2(client_id, client_secret, REDIRECT_URI);
253
- }
254
-
255
- function getGoogleAuthenticatedClient(account: string) {
256
- const credentials = loadGoogleCredentials();
257
- const oauth2Client = createGoogleOAuth2Client(credentials);
258
-
259
- // Try new path first, then legacy path
260
- let tokenPath = getGoogleTokenPath(account);
261
- if (!existsSync(tokenPath)) {
262
- const legacyPath = getLegacyTokenPath(account);
263
- if (existsSync(legacyPath)) {
264
- tokenPath = legacyPath;
265
- } else {
266
- return null;
267
- }
268
- }
269
-
270
- const token = JSON.parse(readFileSync(tokenPath, "utf-8"));
271
- oauth2Client.setCredentials(token);
272
-
273
- oauth2Client.on("tokens", (tokens) => {
274
- const currentToken = JSON.parse(readFileSync(tokenPath, "utf-8"));
275
- const updatedToken = { ...currentToken, ...tokens };
276
- writeFileSync(tokenPath, JSON.stringify(updatedToken, null, 2));
277
- });
278
-
279
- return oauth2Client;
280
- }
281
-
282
- async function authenticateGoogleAccount(account: string): Promise<void> {
283
- const credentials = loadGoogleCredentials();
284
- const oauth2Client = createGoogleOAuth2Client(credentials);
285
-
286
- const authUrl = oauth2Client.generateAuthUrl({
287
- access_type: "offline",
288
- scope: GOOGLE_SCOPES,
289
- prompt: "consent",
290
- login_hint: account,
291
- });
292
-
293
- console.log(`\nAuthenticating: ${account}`);
294
- console.log(`Opening browser...`);
295
-
296
- const code = await startServerAndGetCode(authUrl);
297
-
298
- console.log(`Exchanging code for tokens...`);
299
-
300
- const { tokens } = await oauth2Client.getToken(code);
301
- oauth2Client.setCredentials(tokens);
302
-
303
- const tokensDir = getTokensDir();
304
- if (!existsSync(tokensDir)) {
305
- mkdirSync(tokensDir, { recursive: true });
306
- }
307
-
308
- const tokenPath = getGoogleTokenPath(account);
309
- writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
310
- console.log(`Token saved for ${account}`);
311
- }
312
-
313
- // ============================================================================
314
- // Zoho OAuth Flow
315
- // ============================================================================
316
-
317
- async function authenticateZohoAccount(account: ZohoAccount): Promise<void> {
318
- const credentials = loadZohoCredentials();
319
- const dc = ZOHO_DATACENTERS[account.datacenter];
320
-
321
- if (!dc) {
322
- throw new Error(`Invalid datacenter: ${account.datacenter}. Valid options: ${Object.keys(ZOHO_DATACENTERS).join(", ")}`);
323
- }
324
-
325
- const params = new URLSearchParams({
326
- response_type: "code",
327
- client_id: credentials.client_id,
328
- scope: ZOHO_SCOPES.join(","),
329
- redirect_uri: ZOHO_REDIRECT_URI,
330
- access_type: "offline",
331
- prompt: "consent",
332
- });
333
-
334
- const authUrl = `${dc.accounts}/oauth/v2/auth?${params.toString()}`;
335
-
336
- console.log(`\nAuthenticating Zoho: ${account.email}`);
337
- console.log(`Datacenter: ${account.datacenter}`);
338
- console.log(`Opening browser...`);
339
-
340
- const code = await startServerAndGetCode(authUrl);
341
-
342
- console.log(`Exchanging code for tokens...`);
343
-
344
- // Exchange code for tokens
345
- const tokenParams = new URLSearchParams({
346
- grant_type: "authorization_code",
347
- client_id: credentials.client_id,
348
- client_secret: credentials.client_secret,
349
- redirect_uri: ZOHO_REDIRECT_URI,
350
- code: code,
351
- });
352
-
353
- const tokenResponse = await fetch(`${dc.accounts}/oauth/v2/token`, {
354
- method: "POST",
355
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
356
- body: tokenParams.toString(),
357
- });
358
-
359
- if (!tokenResponse.ok) {
360
- const errorText = await tokenResponse.text();
361
- throw new Error(`Failed to get Zoho tokens: ${errorText}`);
362
- }
363
-
364
- const tokenData = await tokenResponse.json();
365
-
366
- const token: ZohoToken = {
367
- access_token: tokenData.access_token,
368
- refresh_token: tokenData.refresh_token,
369
- expires_at: Date.now() + (tokenData.expires_in * 1000),
370
- api_domain: tokenData.api_domain || dc.calendar,
371
- };
372
-
373
- const tokensDir = getTokensDir();
374
- if (!existsSync(tokensDir)) {
375
- mkdirSync(tokensDir, { recursive: true });
376
- }
377
-
378
- const tokenPath = getZohoTokenPath(account.email);
379
- writeFileSync(tokenPath, JSON.stringify(token, null, 2));
380
- console.log(`Token saved for ${account.email}`);
381
- }
382
-
383
- async function refreshZohoToken(account: ZohoAccount): Promise<ZohoToken | null> {
384
- const tokenPath = getZohoTokenPath(account.email);
385
- if (!existsSync(tokenPath)) return null;
386
-
387
- const token: ZohoToken = JSON.parse(readFileSync(tokenPath, "utf-8"));
388
-
389
- // Check if token is still valid (with 5 minute buffer)
390
- if (token.expires_at > Date.now() + 300000) {
391
- return token;
392
- }
393
-
394
- // Refresh the token
395
- try {
396
- const credentials = loadZohoCredentials();
397
- const dc = ZOHO_DATACENTERS[account.datacenter];
398
-
399
- const params = new URLSearchParams({
400
- grant_type: "refresh_token",
401
- client_id: credentials.client_id,
402
- client_secret: credentials.client_secret,
403
- refresh_token: token.refresh_token,
404
- });
405
-
406
- const response = await fetch(`${dc.accounts}/oauth/v2/token`, {
407
- method: "POST",
408
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
409
- body: params.toString(),
410
- });
411
-
412
- if (!response.ok) return null;
413
-
414
- const data = await response.json();
415
- const updatedToken: ZohoToken = {
416
- ...token,
417
- access_token: data.access_token,
418
- expires_at: Date.now() + (data.expires_in * 1000),
419
- };
420
-
421
- writeFileSync(tokenPath, JSON.stringify(updatedToken, null, 2));
422
- return updatedToken;
423
- } catch {
424
- return null;
425
- }
426
- }
427
-
428
- function getZohoAuthenticatedToken(account: ZohoAccount): ZohoToken | null {
429
- const tokenPath = getZohoTokenPath(account.email);
430
- if (!existsSync(tokenPath)) return null;
431
- return JSON.parse(readFileSync(tokenPath, "utf-8"));
432
- }
433
-
434
- function startServerAndGetCode(authUrl: string): Promise<string> {
435
- return new Promise((resolve, reject) => {
436
- let server: Server;
437
-
438
- server = createServer(async (req, res) => {
439
- const url = new URL(req.url!, `http://localhost:3000`);
440
-
441
- if (!url.pathname.startsWith("/callback")) {
442
- res.writeHead(404);
443
- res.end("Not found");
444
- return;
445
- }
446
-
447
- const code = url.searchParams.get("code");
448
- const error = url.searchParams.get("error");
449
-
450
- if (error) {
451
- res.writeHead(400, { "Content-Type": "text/html" });
452
- res.end(`<html><body><h1>Authentication failed</h1><p>Error: ${error}</p></body></html>`);
453
- server.close();
454
- reject(new Error(error));
455
- return;
456
- }
457
-
458
- if (code) {
459
- res.writeHead(200, { "Content-Type": "text/html" });
460
- res.end(`
461
- <html>
462
- <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee;">
463
- <div style="text-align: center;">
464
- <h1 style="color: #4ade80;">Authentication Successful!</h1>
465
- <p>You can close this window and return to the terminal.</p>
466
- </div>
467
- </body>
468
- </html>
469
- `);
470
-
471
- setTimeout(() => {
472
- server.close(() => resolve(code));
473
- }, 500);
474
- } else {
475
- res.writeHead(400, { "Content-Type": "text/html" });
476
- res.end("<html><body><h1>Authentication failed</h1><p>No code received.</p></body></html>");
477
- }
478
- });
479
-
480
- server.listen(3000, () => openBrowser(authUrl));
481
-
482
- server.on("error", (err: NodeJS.ErrnoException) => {
483
- if (err.code === "EADDRINUSE") {
484
- reject(new Error("Port 3000 is already in use. Please close any application using it and try again."));
485
- } else {
486
- reject(err);
487
- }
488
- });
489
-
490
- setTimeout(() => {
491
- server.close();
492
- reject(new Error("Authentication timeout (5 minutes)"));
493
- }, 300000);
494
- });
495
- }
496
-
497
- function openBrowser(url: string) {
498
- const { exec } = require("child_process");
499
- const platform = process.platform;
500
-
501
- let command: string;
502
- if (platform === "win32") {
503
- command = `start "" "${url}"`;
504
- } else if (platform === "darwin") {
505
- command = `open "${url}"`;
506
- } else {
507
- command = `xdg-open "${url}"`;
508
- }
509
-
510
- exec(command, (err: Error | null) => {
511
- if (err) {
512
- console.log(`\nCould not open browser automatically.`);
513
- console.log(`Please open this URL manually:\n${url}\n`);
514
- }
515
- });
516
- }
517
-
518
- // ============================================================================
519
- // Calendar
520
- // ============================================================================
521
-
522
- interface CalendarEvent {
523
- id: string;
524
- title: string;
525
- start: Date;
526
- end: Date;
527
- isAllDay: boolean;
528
- account: string;
529
- accountEmail: string;
530
- accountIndex: number;
531
- provider: "google" | "zoho";
532
- }
533
-
534
- // Get all accounts combined for indexing
535
- function getAllAccounts(config: Config): string[] {
536
- const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
537
- const zohoEmails = config.zohoAccounts.map(z => z.email);
538
- return [...gmailAccounts, ...zohoEmails];
539
- }
540
-
541
- async function getGoogleEvents(config: Config, now: Date, timeMax: Date): Promise<CalendarEvent[]> {
542
- const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
543
- const allAccounts = getAllAccounts(config);
544
-
545
- const eventPromises = gmailAccounts.map(async (account) => {
546
- const accountIndex = allAccounts.indexOf(account);
547
- try {
548
- const auth = getGoogleAuthenticatedClient(account);
549
- if (!auth) return [];
550
-
551
- const calendar = google.calendar({ version: "v3", auth });
552
-
553
- const response = await calendar.events.list({
554
- calendarId: "primary",
555
- timeMin: now.toISOString(),
556
- timeMax: timeMax.toISOString(),
557
- maxResults: 10,
558
- singleEvents: true,
559
- orderBy: "startTime",
560
- });
561
-
562
- const events = response.data.items || [];
563
- return events.map((event) => {
564
- const isAllDay = !event.start?.dateTime;
565
- let start: Date, end: Date;
566
-
567
- if (isAllDay) {
568
- start = new Date(event.start?.date + "T00:00:00");
569
- end = new Date(event.end?.date + "T00:00:00");
570
- } else {
571
- start = new Date(event.start?.dateTime!);
572
- end = new Date(event.end?.dateTime!);
573
- }
574
-
575
- return {
576
- id: event.id || "",
577
- title: event.summary || "(No title)",
578
- start,
579
- end,
580
- isAllDay,
581
- account: extractAccountName(account),
582
- accountEmail: account,
583
- accountIndex,
584
- provider: "google" as const,
585
- };
586
- });
587
- } catch {
588
- return [];
589
- }
590
- });
591
-
592
- const results = await Promise.all(eventPromises);
593
- return results.flat();
594
- }
595
-
596
- async function getZohoEvents(config: Config, now: Date, timeMax: Date): Promise<CalendarEvent[]> {
597
- if (!config.zohoAccounts || config.zohoAccounts.length === 0) return [];
598
-
599
- const allAccounts = getAllAccounts(config);
600
- const gmailCount = (config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts).length;
601
-
602
- const eventPromises = config.zohoAccounts.map(async (account, idx) => {
603
- const accountIndex = gmailCount + idx;
604
- try {
605
- const token = await refreshZohoToken(account);
606
- if (!token) return [];
607
-
608
- const dc = ZOHO_DATACENTERS[account.datacenter];
609
- // Always use the calendar-specific API domain, not the generic api_domain
610
- const apiBase = dc.calendar;
611
-
612
- // First get list of calendars
613
- const calendarsResponse = await fetch(`${apiBase}/api/v1/calendars?category=own`, {
614
- headers: {
615
- Authorization: `Zoho-oauthtoken ${token.access_token}`,
616
- },
617
- });
618
-
619
- if (!calendarsResponse.ok) return [];
620
-
621
- const calendarsData = await calendarsResponse.json();
622
- const calendars = calendarsData.calendars || [];
623
-
624
- if (calendars.length === 0) return [];
625
-
626
- // Use the first (primary) calendar
627
- const primaryCalendar = calendars.find((c: any) => c.isdefault) || calendars[0];
628
- const calendarUid = primaryCalendar.uid;
629
-
630
- // Format dates for Zoho API (yyyyMMdd'T'HHmmss'Z')
631
- const formatZohoDate = (date: Date): string => {
632
- return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
633
- };
634
-
635
- const range = JSON.stringify({
636
- start: formatZohoDate(now),
637
- end: formatZohoDate(timeMax),
638
- });
639
-
640
- const eventsResponse = await fetch(
641
- `${apiBase}/api/v1/calendars/${encodeURIComponent(calendarUid)}/events?range=${encodeURIComponent(range)}`,
642
- {
643
- headers: {
644
- Authorization: `Zoho-oauthtoken ${token.access_token}`,
645
- },
646
- }
647
- );
648
-
649
- if (!eventsResponse.ok) return [];
650
-
651
- const eventsData = await eventsResponse.json();
652
- const events = eventsData.events || [];
653
-
654
- return events.map((event: any) => {
655
- const isAllDay = event.isallday === true;
656
- let start: Date, end: Date;
657
-
658
- // Parse Zoho date format: "20260109T163000+0530" or "20260109T163000Z"
659
- const parseZohoDate = (dateStr: string): Date => {
660
- // Format: YYYYMMDDTHHmmss+HHMM or YYYYMMDDTHHmmssZ
661
- const match = dateStr.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})([Z]|([+-])(\d{2})(\d{2}))?$/);
662
- if (match) {
663
- const [, year, month, day, hour, min, sec, tz, sign, tzHour, tzMin] = match;
664
- if (tz === "Z") {
665
- return new Date(Date.UTC(+year, +month - 1, +day, +hour, +min, +sec));
666
- } else if (sign && tzHour && tzMin) {
667
- const offsetMinutes = (+tzHour * 60 + +tzMin) * (sign === "+" ? -1 : 1);
668
- const utc = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
669
- return new Date(utc + offsetMinutes * 60000);
670
- }
671
- // No timezone, assume local
672
- return new Date(+year, +month - 1, +day, +hour, +min, +sec);
673
- }
674
- // Fallback to standard parsing
675
- return new Date(dateStr);
676
- };
677
-
678
- if (event.dateandtime) {
679
- start = parseZohoDate(event.dateandtime.start);
680
- end = parseZohoDate(event.dateandtime.end);
681
- } else {
682
- start = parseZohoDate(event.start);
683
- end = parseZohoDate(event.end);
684
- }
685
-
686
- return {
687
- id: event.uid || "",
688
- title: event.title || "(No title)",
689
- start,
690
- end,
691
- isAllDay,
692
- account: extractAccountName(account.email),
693
- accountEmail: account.email,
694
- accountIndex,
695
- provider: "zoho" as const,
696
- };
697
- });
698
- } catch {
699
- return [];
700
- }
701
- });
702
-
703
- const results = await Promise.all(eventPromises);
704
- return results.flat();
705
- }
706
-
707
- async function getUpcomingEvents(config: Config): Promise<CalendarEvent[]> {
708
- const now = new Date();
709
- const timeMax = new Date(now.getTime() + config.lookaheadHours * 60 * 60 * 1000);
710
-
711
- // Fetch from both providers in parallel
712
- const [googleEvents, zohoEvents] = await Promise.all([
713
- getGoogleEvents(config, now, timeMax),
714
- getZohoEvents(config, now, timeMax),
715
- ]);
716
-
717
- const allEvents = [...googleEvents, ...zohoEvents];
718
- allEvents.sort((a, b) => a.start.getTime() - b.start.getTime());
719
- return allEvents;
720
- }
721
-
722
- // ============================================================================
723
- // Zoho Tasks
724
- // ============================================================================
725
-
726
- interface ZohoTask {
727
- id: string;
728
- title: string;
729
- description: string;
730
- dueDate: Date | null;
731
- priority: "High" | "Normal" | "Low";
732
- status: string;
733
- isOverdue: boolean;
734
- }
735
-
736
- async function getZohoTasks(config: Config): Promise<ZohoTask[]> {
737
- if (!config.zohoAccounts || config.zohoAccounts.length === 0) return [];
738
- if (!config.showZohoTasks) return [];
739
-
740
- const allTasks: ZohoTask[] = [];
741
-
742
- for (const account of config.zohoAccounts) {
743
- try {
744
- const token = await refreshZohoToken(account);
745
- if (!token) continue;
746
-
747
- const dc = ZOHO_DATACENTERS[account.datacenter];
748
- const mailBase = dc.mail;
749
-
750
- // Fetch tasks assigned to user
751
- const response = await fetch(
752
- `${mailBase}/api/tasks/?view=assignedtome&action=view&limit=10&from=0`,
753
- {
754
- headers: {
755
- Authorization: `Zoho-oauthtoken ${token.access_token}`,
756
- Accept: "application/json",
757
- },
758
- }
759
- );
760
-
761
- if (!response.ok) continue;
762
-
763
- const data = await response.json();
764
- const tasks = data.data?.tasks || [];
765
-
766
- const now = new Date();
767
-
768
- for (const task of tasks) {
769
- // Skip completed tasks
770
- if (task.status === "Completed" || task.status === "completed") continue;
771
-
772
- let dueDate: Date | null = null;
773
- let isOverdue = false;
774
-
775
- if (task.dueDate) {
776
- // Parse DD/MM/YYYY format
777
- const parts = task.dueDate.split("/");
778
- if (parts.length === 3) {
779
- dueDate = new Date(+parts[2], +parts[1] - 1, +parts[0]);
780
- isOverdue = dueDate < now;
781
- }
782
- }
783
-
784
- allTasks.push({
785
- id: task.id || "",
786
- title: task.title || "(No title)",
787
- description: task.description || "",
788
- dueDate,
789
- priority: task.priority || "Normal",
790
- status: task.status || "Open",
791
- isOverdue,
792
- });
793
- }
794
- } catch {
795
- // Silently continue on error
796
- }
797
- }
798
-
799
- // Sort: overdue first, then by due date (soonest first), then by priority
800
- allTasks.sort((a, b) => {
801
- // Overdue tasks first
802
- if (a.isOverdue && !b.isOverdue) return -1;
803
- if (!a.isOverdue && b.isOverdue) return 1;
804
-
805
- // Then by due date (null dates go last)
806
- if (a.dueDate && b.dueDate) {
807
- return a.dueDate.getTime() - b.dueDate.getTime();
808
- }
809
- if (a.dueDate && !b.dueDate) return -1;
810
- if (!a.dueDate && b.dueDate) return 1;
811
-
812
- // Then by priority
813
- const priorityOrder = { High: 0, Normal: 1, Low: 2 };
814
- return (priorityOrder[a.priority] || 1) - (priorityOrder[b.priority] || 1);
815
- });
816
-
817
- return allTasks.slice(0, config.maxTasksToShow);
818
- }
819
-
820
- function formatTasks(tasks: ZohoTask[]): string | null {
821
- if (tasks.length === 0) return null;
822
-
823
- const formatted = tasks.map((task) => {
824
- const title = task.title.length > 25 ? task.title.slice(0, 24) + "…" : task.title;
825
-
826
- if (task.isOverdue) {
827
- return `${COLORS.red}${title}${COLORS.reset}`;
828
- } else if (task.priority === "High") {
829
- return `${COLORS.yellow}${title}${COLORS.reset}`;
830
- } else {
831
- return `${COLORS.white}${title}${COLORS.reset}`;
832
- }
833
- });
834
-
835
- return `${COLORS.cyan}Tasks:${COLORS.reset} ${formatted.join(", ")}`;
836
- }
837
-
838
- function extractAccountName(email: string): string {
839
- const atIndex = email.indexOf("@");
840
- if (atIndex === -1) return email;
841
-
842
- const domain = email.slice(atIndex + 1);
843
- if (domain === "gmail.com") {
844
- return email.slice(0, atIndex);
845
- }
846
-
847
- return domain.split(".")[0];
848
- }
849
-
850
- function getCurrentOrNextEvent(events: CalendarEvent[]): CalendarEvent | null {
851
- const now = new Date();
852
-
853
- for (const event of events) {
854
- if (event.start <= now && event.end > now) return event;
855
- }
856
-
857
- for (const event of events) {
858
- if (event.start > now) return event;
859
- }
860
-
861
- return null;
862
- }
863
-
864
- // ============================================================================
865
- // Formatter
866
- // ============================================================================
867
-
868
- function formatEvent(event: CalendarEvent, config: Config): string {
869
- const now = new Date();
870
- const isHappening = event.start <= now && event.end > now;
871
- const minutesUntil = Math.round((event.start.getTime() - now.getTime()) / 60000);
872
-
873
- let timeStr: string;
874
- if (isHappening) {
875
- timeStr = "Now";
876
- } else if (minutesUntil <= config.countdownThresholdMinutes && minutesUntil > 0) {
877
- timeStr = formatCountdown(minutesUntil);
878
- } else {
879
- timeStr = formatTime(event.start);
880
- }
881
-
882
- const title = event.title.length > config.maxTitleLength
883
- ? event.title.slice(0, config.maxTitleLength - 1) + "…"
884
- : event.title;
885
-
886
- const colorName = ACCOUNT_COLORS[event.accountIndex % ACCOUNT_COLORS.length];
887
- const color = COLORS[colorName] || COLORS.white;
888
-
889
- if (config.showCalendarName) {
890
- return `${color}${timeStr}: ${title} (${event.account})${COLORS.reset}`;
891
- }
892
- return `${color}${timeStr}: ${title}${COLORS.reset}`;
893
- }
894
-
895
- function formatCountdown(minutes: number): string {
896
- if (minutes < 60) return `In ${minutes}m`;
897
- const hours = Math.floor(minutes / 60);
898
- const mins = minutes % 60;
899
- return mins === 0 ? `In ${hours}h` : `In ${hours}h${mins}m`;
900
- }
901
-
902
- function getMeetingWarning(event: CalendarEvent | null): string | null {
903
- if (!event) return null;
904
-
905
- const now = new Date();
906
- const minutesUntil = Math.round((event.start.getTime() - now.getTime()) / 60000);
907
-
908
- // Warning when meeting is 5 minutes or less away
909
- if (minutesUntil > 0 && minutesUntil <= 5) {
910
- return `${COLORS.brightRed}Meeting in ${minutesUntil}m - wrap up!${COLORS.reset}`;
911
- }
912
-
913
- return null;
914
- }
915
-
916
- // ============================================================================
917
- // System Stats
918
- // ============================================================================
919
-
920
- function getCpuUsage(): string | null {
921
- try {
922
- const os = require("os");
923
- const cpus = os.cpus();
924
-
925
- let totalIdle = 0;
926
- let totalTick = 0;
927
-
928
- for (const cpu of cpus) {
929
- for (const type in cpu.times) {
930
- totalTick += cpu.times[type as keyof typeof cpu.times];
931
- }
932
- totalIdle += cpu.times.idle;
933
- }
934
-
935
- const usage = Math.round(100 - (totalIdle / totalTick) * 100);
936
-
937
- // Color based on usage
938
- let color = COLORS.green;
939
- if (usage >= 80) color = COLORS.red;
940
- else if (usage >= 50) color = COLORS.yellow;
941
-
942
- return `${color}CPU ${usage}%${COLORS.reset}`;
943
- } catch {
944
- return null;
945
- }
946
- }
947
-
948
- function getMemoryUsage(): string | null {
949
- try {
950
- const os = require("os");
951
- const totalMem = os.totalmem();
952
- const freeMem = os.freemem();
953
- const usedMem = totalMem - freeMem;
954
- const usagePercent = Math.round((usedMem / totalMem) * 100);
955
-
956
- // Format used memory
957
- const usedGB = (usedMem / (1024 * 1024 * 1024)).toFixed(1);
958
- const totalGB = (totalMem / (1024 * 1024 * 1024)).toFixed(1);
959
-
960
- // Color based on usage
961
- let color = COLORS.green;
962
- if (usagePercent >= 80) color = COLORS.red;
963
- else if (usagePercent >= 50) color = COLORS.yellow;
964
-
965
- return `${color}Mem ${usedGB}/${totalGB}GB${COLORS.reset}`;
966
- } catch {
967
- return null;
968
- }
969
- }
970
-
971
- function formatTime(date: Date): string {
972
- const hours = date.getHours();
973
- const minutes = date.getMinutes();
974
- const isPM = hours >= 12;
975
- const hour12 = hours % 12 || 12;
976
- return `${hour12}:${minutes.toString().padStart(2, "0")} ${isPM ? "PM" : "AM"}`;
977
- }
978
-
979
- // ============================================================================
980
- // CLI Commands
981
- // ============================================================================
982
-
983
- function printHelp() {
984
- console.log(`
985
- glancebar - A customizable statusline for Claude Code
986
-
987
- Display calendar events, tasks, and more at a glance.
988
-
989
- Usage:
990
- glancebar Output statusline (for Claude Code)
991
- glancebar auth Authenticate all configured accounts
992
- glancebar auth --add <email> Add and authenticate a new account (prompts for provider)
993
- glancebar auth --remove <email> Remove an account
994
- glancebar auth --list List all configured accounts
995
- glancebar config Show current configuration
996
- glancebar config --lookahead <hours> Set lookahead hours (default: 8)
997
- glancebar config --countdown-threshold <mins> Set countdown threshold in minutes (default: 60)
998
- glancebar config --max-title <length> Set max title length (default: 120)
999
- glancebar config --show-calendar <true|false> Show calendar name (default: true)
1000
- glancebar config --water-reminder <true|false> Enable/disable water reminders (default: true)
1001
- glancebar config --stretch-reminder <true|false> Enable/disable stretch reminders (default: true)
1002
- glancebar config --eye-reminder <true|false> Enable/disable eye break reminders (default: true)
1003
- glancebar config --cpu-usage <true|false> Show CPU usage (default: false)
1004
- glancebar config --memory-usage <true|false> Show memory usage (default: false)
1005
- glancebar config --zoho-tasks <true|false> Show Zoho tasks (default: true)
1006
- glancebar config --max-tasks <number> Max tasks to show (default: 3)
1007
- glancebar config --reset Reset to default configuration
1008
- glancebar setup Show setup instructions
1009
- glancebar --version Show version
1010
-
1011
- Examples:
1012
- glancebar auth --add user@gmail.com # Will prompt for Google or Zoho
1013
- glancebar auth --add user@zoho.com # Will prompt for Google or Zoho
1014
- glancebar config --lookahead 12
1015
- glancebar config --stretch-reminder false
1016
-
1017
- Config location: ${getConfigDir()}
1018
- `);
1019
- }
1020
-
1021
- function printSetup() {
1022
- console.log(`
1023
- Glancebar - Setup Instructions
1024
- ==============================
1025
-
1026
- GOOGLE CALENDAR SETUP
1027
- ---------------------
1028
-
1029
- Step 1: Create Google Cloud Project
1030
- - Go to https://console.cloud.google.com/
1031
- - Create a new project or select existing one
1032
-
1033
- Step 2: Enable Google Calendar API
1034
- - Go to "APIs & Services" > "Library"
1035
- - Search for "Google Calendar API" and enable it
1036
-
1037
- Step 3: Create OAuth Credentials
1038
- - Go to "APIs & Services" > "Credentials"
1039
- - Click "Create Credentials" > "OAuth client ID"
1040
- - Select "Desktop app" as application type
1041
- - Download the JSON file
1042
-
1043
- Step 4: Save credentials
1044
- - Rename downloaded file to "credentials.json"
1045
- - Save it to: ${getGoogleCredentialsPath()}
1046
-
1047
- Step 5: Add redirect URI
1048
- - Edit credentials.json and ensure redirect_uris contains:
1049
- "redirect_uris": ["http://localhost:3000/callback"]
1050
-
1051
- ZOHO CALENDAR SETUP
1052
- -------------------
1053
-
1054
- Step 1: Register Application
1055
- - Go to https://api-console.zoho.com/
1056
- - Click "Add Client" > "Server-based Applications"
1057
-
1058
- Step 2: Configure Client
1059
- - Set Authorized Redirect URI: http://localhost:3000/callback
1060
- - Note your Client ID and Client Secret
1061
-
1062
- Step 3: Save credentials
1063
- - Create file: ${getZohoCredentialsPath()}
1064
- - Add content:
1065
- {
1066
- "client_id": "YOUR_CLIENT_ID",
1067
- "client_secret": "YOUR_CLIENT_SECRET"
1068
- }
1069
-
1070
- ADDING ACCOUNTS
1071
- ---------------
1072
-
1073
- glancebar auth --add your-email@gmail.com
1074
- # Select "Google" or "Zoho" when prompted
1075
- # For Zoho, select your datacenter region
1076
-
1077
- CONFIGURE CLAUDE CODE
1078
- ---------------------
1079
-
1080
- Update ~/.claude/settings.json:
1081
- {
1082
- "statusLine": {
1083
- "type": "command",
1084
- "command": "bunx @naarang/glancebar",
1085
- "padding": 0
1086
- }
1087
- }
1088
-
1089
- For more info: https://github.com/vishal-android-freak/glancebar
1090
- `);
1091
- }
1092
-
1093
- async function handleAuth(args: string[]) {
1094
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1095
- const prompt = (q: string): Promise<string> => new Promise((r) => rl.question(q, r));
1096
-
1097
- // Handle --list
1098
- if (args.includes("--list")) {
1099
- const config = loadConfig();
1100
- const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
1101
- const hasAny = gmailAccounts.length > 0 || config.zohoAccounts.length > 0;
1102
-
1103
- if (!hasAny) {
1104
- console.log("No accounts configured.");
1105
- } else {
1106
- if (gmailAccounts.length > 0) {
1107
- console.log("\nGoogle Calendar accounts:");
1108
- gmailAccounts.forEach((acc, i) => {
1109
- let tokenPath = getGoogleTokenPath(acc);
1110
- if (!existsSync(tokenPath)) {
1111
- tokenPath = getLegacyTokenPath(acc);
1112
- }
1113
- const status = existsSync(tokenPath) ? "authenticated" : "not authenticated";
1114
- console.log(` ${i + 1}. ${acc} (${status})`);
1115
- });
1116
- }
1117
-
1118
- if (config.zohoAccounts.length > 0) {
1119
- console.log("\nZoho Calendar accounts:");
1120
- config.zohoAccounts.forEach((acc, i) => {
1121
- const tokenPath = getZohoTokenPath(acc.email);
1122
- const status = existsSync(tokenPath) ? "authenticated" : "not authenticated";
1123
- console.log(` ${i + 1}. ${acc.email} [${acc.datacenter}] (${status})`);
1124
- });
1125
- }
1126
- }
1127
- rl.close();
1128
- return;
1129
- }
1130
-
1131
- // Handle --add
1132
- const addIndex = args.indexOf("--add");
1133
- if (addIndex !== -1) {
1134
- const email = args[addIndex + 1];
1135
- if (!email || email.startsWith("--")) {
1136
- console.error("Error: Please provide an email address after --add");
1137
- rl.close();
1138
- process.exit(1);
1139
- }
1140
-
1141
- if (!email.includes("@")) {
1142
- console.error("Error: Invalid email address");
1143
- rl.close();
1144
- process.exit(1);
1145
- }
1146
-
1147
- // Prompt for provider
1148
- console.log("\nSelect calendar provider:");
1149
- console.log(" 1. Google Calendar");
1150
- console.log(" 2. Zoho Calendar");
1151
- const providerChoice = await prompt("\nEnter choice (1 or 2): ");
1152
-
1153
- const config = loadConfig();
1154
-
1155
- if (providerChoice === "1") {
1156
- // Google Calendar
1157
- const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
1158
- if (gmailAccounts.includes(email)) {
1159
- console.log(`\nGoogle account ${email} already exists. Re-authenticating...`);
1160
- } else {
1161
- if (config.gmailAccounts.length === 0 && config.accounts.length > 0) {
1162
- config.gmailAccounts = [...config.accounts];
1163
- }
1164
- config.gmailAccounts.push(email);
1165
- saveConfig(config);
1166
- console.log(`\nAdded ${email} to Google accounts.`);
1167
- }
1168
-
1169
- await authenticateGoogleAccount(email);
1170
- console.log("\nDone!");
1171
- } else if (providerChoice === "2") {
1172
- // Zoho Calendar
1173
- console.log("\nSelect Zoho datacenter:");
1174
- console.log(" 1. com - United States");
1175
- console.log(" 2. eu - Europe");
1176
- console.log(" 3. in - India");
1177
- console.log(" 4. com.au - Australia");
1178
- console.log(" 5. com.cn - China");
1179
- console.log(" 6. jp - Japan");
1180
- console.log(" 7. zohocloud.ca - Canada");
1181
-
1182
- const dcChoice = await prompt("\nEnter choice (1-7): ");
1183
- const dcMap: Record<string, string> = {
1184
- "1": "com",
1185
- "2": "eu",
1186
- "3": "in",
1187
- "4": "com.au",
1188
- "5": "com.cn",
1189
- "6": "jp",
1190
- "7": "zohocloud.ca",
1191
- };
1192
-
1193
- const datacenter = dcMap[dcChoice];
1194
- if (!datacenter) {
1195
- console.error("Error: Invalid datacenter choice");
1196
- rl.close();
1197
- process.exit(1);
1198
- }
1199
-
1200
- const existingZoho = config.zohoAccounts.find((z) => z.email === email);
1201
- if (existingZoho) {
1202
- console.log(`\nZoho account ${email} already exists. Re-authenticating...`);
1203
- existingZoho.datacenter = datacenter;
1204
- saveConfig(config);
1205
- } else {
1206
- config.zohoAccounts.push({ email, datacenter });
1207
- saveConfig(config);
1208
- console.log(`\nAdded ${email} to Zoho accounts.`);
1209
- }
1210
-
1211
- await authenticateZohoAccount({ email, datacenter });
1212
- console.log("\nDone!");
1213
- } else {
1214
- console.error("Error: Invalid choice. Please enter 1 or 2.");
1215
- rl.close();
1216
- process.exit(1);
1217
- }
1218
-
1219
- rl.close();
1220
- return;
1221
- }
1222
-
1223
- // Handle --remove
1224
- const removeIndex = args.indexOf("--remove");
1225
- if (removeIndex !== -1) {
1226
- const email = args[removeIndex + 1];
1227
- if (!email || email.startsWith("--")) {
1228
- console.error("Error: Please provide an email address after --remove");
1229
- rl.close();
1230
- process.exit(1);
1231
- }
1232
-
1233
- const config = loadConfig();
1234
- const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
1235
-
1236
- // Check Google accounts
1237
- const googleIdx = gmailAccounts.indexOf(email);
1238
- if (googleIdx !== -1) {
1239
- if (config.gmailAccounts.length > 0) {
1240
- config.gmailAccounts.splice(googleIdx, 1);
1241
- } else {
1242
- config.accounts.splice(googleIdx, 1);
1243
- }
1244
- saveConfig(config);
1245
-
1246
- // Remove token files
1247
- const tokenPath = getGoogleTokenPath(email);
1248
- const legacyPath = getLegacyTokenPath(email);
1249
- if (existsSync(tokenPath)) unlinkSync(tokenPath);
1250
- if (existsSync(legacyPath)) unlinkSync(legacyPath);
1251
-
1252
- console.log(`Removed Google account ${email}.`);
1253
- rl.close();
1254
- return;
1255
- }
1256
-
1257
- // Check Zoho accounts
1258
- const zohoIdx = config.zohoAccounts.findIndex((z) => z.email === email);
1259
- if (zohoIdx !== -1) {
1260
- config.zohoAccounts.splice(zohoIdx, 1);
1261
- saveConfig(config);
1262
-
1263
- const tokenPath = getZohoTokenPath(email);
1264
- if (existsSync(tokenPath)) unlinkSync(tokenPath);
1265
-
1266
- console.log(`Removed Zoho account ${email}.`);
1267
- rl.close();
1268
- return;
1269
- }
1270
-
1271
- console.error(`Error: Account ${email} not found.`);
1272
- rl.close();
1273
- process.exit(1);
1274
- }
1275
-
1276
- // Default: authenticate all accounts
1277
- const config = loadConfig();
1278
- const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
1279
- const hasAny = gmailAccounts.length > 0 || config.zohoAccounts.length > 0;
1280
-
1281
- if (!hasAny) {
1282
- console.log("No accounts configured.\n");
1283
- console.log("Add an account using:");
1284
- console.log(" glancebar auth --add your-email@gmail.com\n");
1285
- rl.close();
1286
- return;
1287
- }
1288
-
1289
- console.log("Glancebar - Calendar Authentication");
1290
- console.log("====================================\n");
1291
-
1292
- // Authenticate Google accounts
1293
- for (const account of gmailAccounts) {
1294
- let tokenPath = getGoogleTokenPath(account);
1295
- if (!existsSync(tokenPath)) {
1296
- tokenPath = getLegacyTokenPath(account);
1297
- }
1298
- if (existsSync(tokenPath)) {
1299
- console.log(`Google: ${account} - Already authenticated`);
1300
- const answer = await prompt(`Re-authenticate? (y/N): `);
1301
- if (answer.toLowerCase() !== "y") continue;
1302
- }
1303
- await authenticateGoogleAccount(account);
1304
- }
1305
-
1306
- // Authenticate Zoho accounts
1307
- for (const account of config.zohoAccounts) {
1308
- const tokenPath = getZohoTokenPath(account.email);
1309
- if (existsSync(tokenPath)) {
1310
- console.log(`Zoho: ${account.email} [${account.datacenter}] - Already authenticated`);
1311
- const answer = await prompt(`Re-authenticate? (y/N): `);
1312
- if (answer.toLowerCase() !== "y") continue;
1313
- }
1314
- await authenticateZohoAccount(account);
1315
- }
1316
-
1317
- rl.close();
1318
- console.log("\nAll accounts authenticated!");
1319
- }
1320
-
1321
- function handleConfig(args: string[]) {
1322
- const config = loadConfig();
1323
-
1324
- // Handle --reset
1325
- if (args.includes("--reset")) {
1326
- // Preserve all account types
1327
- const { accounts, gmailAccounts, zohoAccounts } = config;
1328
- saveConfig({ ...DEFAULT_CONFIG, accounts, gmailAccounts, zohoAccounts });
1329
- console.log("Configuration reset to defaults (accounts preserved).");
1330
- return;
1331
- }
1332
-
1333
- // Handle --lookahead
1334
- const lookaheadIndex = args.indexOf("--lookahead");
1335
- if (lookaheadIndex !== -1) {
1336
- const value = parseInt(args[lookaheadIndex + 1], 10);
1337
- if (isNaN(value) || value < 1 || value > 168) {
1338
- console.error("Error: lookahead must be between 1 and 168 hours");
1339
- process.exit(1);
1340
- }
1341
- config.lookaheadHours = value;
1342
- saveConfig(config);
1343
- console.log(`Lookahead hours set to ${value}`);
1344
- return;
1345
- }
1346
-
1347
- // Handle --countdown-threshold
1348
- const countdownIndex = args.indexOf("--countdown-threshold");
1349
- if (countdownIndex !== -1) {
1350
- const value = parseInt(args[countdownIndex + 1], 10);
1351
- if (isNaN(value) || value < 0 || value > 1440) {
1352
- console.error("Error: countdown-threshold must be between 0 and 1440 minutes");
1353
- process.exit(1);
1354
- }
1355
- config.countdownThresholdMinutes = value;
1356
- saveConfig(config);
1357
- console.log(`Countdown threshold set to ${value} minutes`);
1358
- return;
1359
- }
1360
-
1361
- // Handle --max-title
1362
- const maxTitleIndex = args.indexOf("--max-title");
1363
- if (maxTitleIndex !== -1) {
1364
- const value = parseInt(args[maxTitleIndex + 1], 10);
1365
- if (isNaN(value) || value < 10 || value > 500) {
1366
- console.error("Error: max-title must be between 10 and 500");
1367
- process.exit(1);
1368
- }
1369
- config.maxTitleLength = value;
1370
- saveConfig(config);
1371
- console.log(`Max title length set to ${value}`);
1372
- return;
1373
- }
1374
-
1375
- // Handle --show-calendar
1376
- const showCalIndex = args.indexOf("--show-calendar");
1377
- if (showCalIndex !== -1) {
1378
- const value = args[showCalIndex + 1]?.toLowerCase();
1379
- if (value !== "true" && value !== "false") {
1380
- console.error("Error: --show-calendar must be 'true' or 'false'");
1381
- process.exit(1);
1382
- }
1383
- config.showCalendarName = value === "true";
1384
- saveConfig(config);
1385
- console.log(`Show calendar name set to ${value}`);
1386
- return;
1387
- }
1388
-
1389
- // Handle --water-reminder
1390
- const waterReminderIndex = args.indexOf("--water-reminder");
1391
- if (waterReminderIndex !== -1) {
1392
- const value = args[waterReminderIndex + 1]?.toLowerCase();
1393
- if (value !== "true" && value !== "false") {
1394
- console.error("Error: --water-reminder must be 'true' or 'false'");
1395
- process.exit(1);
1396
- }
1397
- config.waterReminderEnabled = value === "true";
1398
- saveConfig(config);
1399
- console.log(`Water reminder ${value === "true" ? "enabled" : "disabled"}`);
1400
- return;
1401
- }
1402
-
1403
- // Handle --stretch-reminder
1404
- const stretchReminderIndex = args.indexOf("--stretch-reminder");
1405
- if (stretchReminderIndex !== -1) {
1406
- const value = args[stretchReminderIndex + 1]?.toLowerCase();
1407
- if (value !== "true" && value !== "false") {
1408
- console.error("Error: --stretch-reminder must be 'true' or 'false'");
1409
- process.exit(1);
1410
- }
1411
- config.stretchReminderEnabled = value === "true";
1412
- saveConfig(config);
1413
- console.log(`Stretch reminder ${value === "true" ? "enabled" : "disabled"}`);
1414
- return;
1415
- }
1416
-
1417
- // Handle --eye-reminder
1418
- const eyeReminderIndex = args.indexOf("--eye-reminder");
1419
- if (eyeReminderIndex !== -1) {
1420
- const value = args[eyeReminderIndex + 1]?.toLowerCase();
1421
- if (value !== "true" && value !== "false") {
1422
- console.error("Error: --eye-reminder must be 'true' or 'false'");
1423
- process.exit(1);
1424
- }
1425
- config.eyeReminderEnabled = value === "true";
1426
- saveConfig(config);
1427
- console.log(`Eye break reminder ${value === "true" ? "enabled" : "disabled"}`);
1428
- return;
1429
- }
1430
-
1431
- // Handle --cpu-usage
1432
- const cpuUsageIndex = args.indexOf("--cpu-usage");
1433
- if (cpuUsageIndex !== -1) {
1434
- const value = args[cpuUsageIndex + 1]?.toLowerCase();
1435
- if (value !== "true" && value !== "false") {
1436
- console.error("Error: --cpu-usage must be 'true' or 'false'");
1437
- process.exit(1);
1438
- }
1439
- config.showCpuUsage = value === "true";
1440
- saveConfig(config);
1441
- console.log(`CPU usage display ${value === "true" ? "enabled" : "disabled"}`);
1442
- return;
1443
- }
1444
-
1445
- // Handle --memory-usage
1446
- const memoryUsageIndex = args.indexOf("--memory-usage");
1447
- if (memoryUsageIndex !== -1) {
1448
- const value = args[memoryUsageIndex + 1]?.toLowerCase();
1449
- if (value !== "true" && value !== "false") {
1450
- console.error("Error: --memory-usage must be 'true' or 'false'");
1451
- process.exit(1);
1452
- }
1453
- config.showMemoryUsage = value === "true";
1454
- saveConfig(config);
1455
- console.log(`Memory usage display ${value === "true" ? "enabled" : "disabled"}`);
1456
- return;
1457
- }
1458
-
1459
- // Handle --zoho-tasks
1460
- const zohoTasksIndex = args.indexOf("--zoho-tasks");
1461
- if (zohoTasksIndex !== -1) {
1462
- const value = args[zohoTasksIndex + 1]?.toLowerCase();
1463
- if (value !== "true" && value !== "false") {
1464
- console.error("Error: --zoho-tasks must be 'true' or 'false'");
1465
- process.exit(1);
1466
- }
1467
- config.showZohoTasks = value === "true";
1468
- saveConfig(config);
1469
- console.log(`Zoho tasks display ${value === "true" ? "enabled" : "disabled"}`);
1470
- return;
1471
- }
1472
-
1473
- // Handle --max-tasks
1474
- const maxTasksIndex = args.indexOf("--max-tasks");
1475
- if (maxTasksIndex !== -1) {
1476
- const value = parseInt(args[maxTasksIndex + 1], 10);
1477
- if (isNaN(value) || value < 1 || value > 10) {
1478
- console.error("Error: --max-tasks must be between 1 and 10");
1479
- process.exit(1);
1480
- }
1481
- config.maxTasksToShow = value;
1482
- saveConfig(config);
1483
- console.log(`Max tasks to show set to ${value}`);
1484
- return;
1485
- }
1486
-
1487
- // Show current config
1488
- const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
1489
- const googleStr = gmailAccounts.length > 0 ? gmailAccounts.join(", ") : "(none)";
1490
- const zohoStr = config.zohoAccounts.length > 0
1491
- ? config.zohoAccounts.map((z) => `${z.email} [${z.datacenter}]`).join(", ")
1492
- : "(none)";
1493
-
1494
- console.log(`
1495
- Glancebar Configuration
1496
- =======================
1497
- Config directory: ${getConfigDir()}
1498
-
1499
- Accounts:
1500
- Google Calendar: ${googleStr}
1501
- Zoho Calendar: ${zohoStr}
1502
-
1503
- Calendar Settings:
1504
- Lookahead hours: ${config.lookaheadHours}
1505
- Countdown threshold: ${config.countdownThresholdMinutes} minutes
1506
- Max title length: ${config.maxTitleLength}
1507
- Show calendar name: ${config.showCalendarName}
1508
-
1509
- Reminders:
1510
- Water reminder: ${config.waterReminderEnabled ? "enabled" : "disabled"}
1511
- Stretch reminder: ${config.stretchReminderEnabled ? "enabled" : "disabled"}
1512
- Eye break reminder: ${config.eyeReminderEnabled ? "enabled" : "disabled"}
1513
-
1514
- System Stats:
1515
- CPU usage: ${config.showCpuUsage ? "enabled" : "disabled"}
1516
- Memory usage: ${config.showMemoryUsage ? "enabled" : "disabled"}
1517
-
1518
- Zoho Tasks:
1519
- Show tasks: ${config.showZohoTasks ? "enabled" : "disabled"}
1520
- Max tasks to show: ${config.maxTasksToShow}
1521
- `);
1522
- }
1523
-
1524
- function getRandomReminder(config: Config): string | null {
1525
- const enabledReminders: Array<() => string> = [];
1526
-
1527
- if (config.waterReminderEnabled) {
1528
- enabledReminders.push(() => {
1529
- const reminder = WATER_REMINDERS[Math.floor(Math.random() * WATER_REMINDERS.length)];
1530
- return `${COLORS.brightCyan}${reminder}${COLORS.reset}`;
1531
- });
1532
- }
1533
-
1534
- if (config.stretchReminderEnabled) {
1535
- enabledReminders.push(() => {
1536
- const reminder = STRETCH_REMINDERS[Math.floor(Math.random() * STRETCH_REMINDERS.length)];
1537
- return `${COLORS.brightGreen}${reminder}${COLORS.reset}`;
1538
- });
1539
- }
1540
-
1541
- if (config.eyeReminderEnabled) {
1542
- enabledReminders.push(() => {
1543
- const reminder = EYE_REMINDERS[Math.floor(Math.random() * EYE_REMINDERS.length)];
1544
- return `${COLORS.brightMagenta}${reminder}${COLORS.reset}`;
1545
- });
1546
- }
1547
-
1548
- if (enabledReminders.length === 0) return null;
1549
-
1550
- // ~5% chance to show any reminder (reduced from 30% to be less intrusive)
1551
- if (Math.random() >= 0.05) return null;
1552
-
1553
- // Pick a random reminder type from enabled ones
1554
- const randomPicker = enabledReminders[Math.floor(Math.random() * enabledReminders.length)];
1555
- return randomPicker();
1556
- }
1557
-
1558
- interface ClaudeCodeStatus {
1559
- model?: { display_name?: string };
1560
- cost?: {
1561
- total_cost_usd?: number;
1562
- total_lines_added?: number;
1563
- total_lines_removed?: number;
1564
- };
1565
- cwd?: string;
1566
- workspace?: {
1567
- project_dir?: string;
1568
- };
1569
- context_window?: {
1570
- context_window_size?: number;
1571
- current_usage?: {
1572
- input_tokens?: number;
1573
- output_tokens?: number;
1574
- cache_creation_input_tokens?: number;
1575
- cache_read_input_tokens?: number;
1576
- };
1577
- };
1578
- }
1579
-
1580
- function getGitBranch(cwd?: string): string | null {
1581
- try {
1582
- const { execSync } = require("child_process");
1583
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
1584
- cwd: cwd || process.cwd(),
1585
- encoding: "utf-8",
1586
- stdio: ["pipe", "pipe", "pipe"],
1587
- }).trim();
1588
-
1589
- // Check if there are uncommitted changes
1590
- let isDirty = false;
1591
- try {
1592
- const status = execSync("git status --porcelain", {
1593
- cwd: cwd || process.cwd(),
1594
- encoding: "utf-8",
1595
- stdio: ["pipe", "pipe", "pipe"],
1596
- }).trim();
1597
- isDirty = status.length > 0;
1598
- } catch {}
1599
-
1600
- return isDirty ? `${branch}*` : branch;
1601
- } catch {
1602
- return null;
1603
- }
1604
- }
1605
-
1606
- function getProjectName(projectDir?: string): string | null {
1607
- if (!projectDir) return null;
1608
- // Extract the last part of the path as project name
1609
- const parts = projectDir.replace(/\\/g, "/").split("/").filter(Boolean);
1610
- return parts.length > 0 ? parts[parts.length - 1] : null;
1611
- }
1612
-
1613
- async function readStdinJson(): Promise<ClaudeCodeStatus | null> {
1614
- try {
1615
- const chunks: Uint8Array[] = [];
1616
- for await (const chunk of Bun.stdin.stream()) {
1617
- chunks.push(chunk);
1618
- }
1619
- if (chunks.length === 0) return null;
1620
- const text = Buffer.concat(chunks).toString("utf-8").trim();
1621
- if (!text) return null;
1622
- return JSON.parse(text);
1623
- } catch {
1624
- return null;
1625
- }
1626
- }
1627
-
1628
- function formatSessionInfo(status: ClaudeCodeStatus): string {
1629
- const parts: string[] = [];
1630
-
1631
- // Project name
1632
- const projectName = getProjectName(status.workspace?.project_dir);
1633
- if (projectName) {
1634
- parts.push(`${COLORS.brightBlue}${projectName}${COLORS.reset}`);
1635
- }
1636
-
1637
- // Git branch
1638
- const gitBranch = getGitBranch(status.cwd || status.workspace?.project_dir);
1639
- if (gitBranch) {
1640
- parts.push(`${COLORS.magenta}${gitBranch}${COLORS.reset}`);
1641
- }
1642
-
1643
- // Model name
1644
- if (status.model?.display_name) {
1645
- parts.push(`${COLORS.brightYellow}${status.model.display_name}${COLORS.reset}`);
1646
- }
1647
-
1648
- // Cost
1649
- if (status.cost?.total_cost_usd !== undefined) {
1650
- const cost = status.cost.total_cost_usd;
1651
- const costStr = cost < 0.01 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
1652
- parts.push(`${COLORS.green}${costStr}${COLORS.reset}`);
1653
- }
1654
-
1655
- // Lines changed
1656
- const linesAdded = status.cost?.total_lines_added || 0;
1657
- const linesRemoved = status.cost?.total_lines_removed || 0;
1658
- if (linesAdded > 0 || linesRemoved > 0) {
1659
- const linesStr = `${COLORS.green}+${linesAdded}${COLORS.reset} ${COLORS.red}-${linesRemoved}${COLORS.reset}`;
1660
- parts.push(linesStr);
1661
- }
1662
-
1663
- // Context usage (using current_usage for accurate context window state)
1664
- if (status.context_window?.current_usage && status.context_window?.context_window_size) {
1665
- const usage = status.context_window.current_usage;
1666
- // Sum all token types for total context usage
1667
- const totalUsed =
1668
- (usage.input_tokens || 0) +
1669
- (usage.output_tokens || 0) +
1670
- (usage.cache_creation_input_tokens || 0) +
1671
- (usage.cache_read_input_tokens || 0);
1672
- const windowSize = status.context_window.context_window_size;
1673
- const percentage = Math.round((totalUsed / windowSize) * 100);
1674
- const usedK = (totalUsed / 1000).toFixed(1);
1675
- const windowK = Math.round(windowSize / 1000);
1676
-
1677
- // Color based on usage: green < 50%, yellow 50-80%, red > 80%
1678
- let color = COLORS.green;
1679
- if (percentage >= 80) color = COLORS.red;
1680
- else if (percentage >= 50) color = COLORS.yellow;
1681
-
1682
- parts.push(`${color}${usedK}k/${windowK}k (${percentage}%)${COLORS.reset}`);
1683
- }
1684
-
1685
- return parts.join(" | ");
1686
- }
1687
-
1688
- async function outputStatusline() {
1689
- // Read and parse stdin from Claude Code
1690
- const status = await readStdinJson();
1691
-
1692
- try {
1693
- const config = loadConfig();
1694
- const parts: string[] = [];
1695
-
1696
- // Add session info from Claude Code
1697
- if (status) {
1698
- const sessionInfo = formatSessionInfo(status);
1699
- if (sessionInfo) {
1700
- parts.push(sessionInfo);
1701
- }
1702
- }
1703
-
1704
- // Add system stats if enabled
1705
- if (config.showCpuUsage) {
1706
- const cpu = getCpuUsage();
1707
- if (cpu) parts.push(cpu);
1708
- }
1709
- if (config.showMemoryUsage) {
1710
- const mem = getMemoryUsage();
1711
- if (mem) parts.push(mem);
1712
- }
1713
-
1714
- // Check for health reminder (water, stretch, eye break)
1715
- const reminder = getRandomReminder(config);
1716
- if (reminder) {
1717
- parts.push(reminder);
1718
- }
1719
-
1720
- // Get calendar events and tasks in parallel
1721
- const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
1722
- const hasAccounts = gmailAccounts.length > 0 || config.zohoAccounts.length > 0;
1723
-
1724
- if (hasAccounts) {
1725
- const [events, tasks] = await Promise.all([
1726
- getUpcomingEvents(config),
1727
- getZohoTasks(config),
1728
- ]);
1729
-
1730
- const event = getCurrentOrNextEvent(events);
1731
-
1732
- // Check for meeting warning (within 5 minutes)
1733
- const meetingWarning = getMeetingWarning(event);
1734
- if (meetingWarning) {
1735
- parts.push(meetingWarning);
1736
- }
1737
-
1738
- // Add tasks if available
1739
- const tasksStr = formatTasks(tasks);
1740
- if (tasksStr) {
1741
- parts.push(tasksStr);
1742
- }
1743
-
1744
- if (event) {
1745
- parts.push(formatEvent(event, config));
1746
- } else if (parts.length === 0) {
1747
- parts.push("No upcoming events");
1748
- }
1749
- } else if (parts.length === 0) {
1750
- parts.push("No accounts configured");
1751
- }
1752
-
1753
- console.log(parts.join(" | "));
1754
- } catch {
1755
- console.log("Calendar unavailable");
1756
- }
1757
- }
1758
-
1759
- // ============================================================================
1760
- // Main
1761
- // ============================================================================
1762
-
1763
- async function main() {
1764
- const args = process.argv.slice(2);
1765
- const command = args[0];
1766
-
1767
- if (!command) {
1768
- // Default: output statusline
1769
- await outputStatusline();
1770
- return;
1771
- }
1772
-
1773
- switch (command) {
1774
- case "version":
1775
- case "--version":
1776
- case "-v":
1777
- console.log(`@naarang/glancebar v${VERSION}`);
1778
- break;
1779
-
1780
- case "help":
1781
- case "--help":
1782
- case "-h":
1783
- printHelp();
1784
- break;
1785
-
1786
- case "setup":
1787
- printSetup();
1788
- break;
1789
-
1790
- case "auth":
1791
- await handleAuth(args.slice(1));
1792
- break;
1793
-
1794
- case "config":
1795
- handleConfig(args.slice(1));
1796
- break;
1797
-
1798
- default:
1799
- console.error(`Unknown command: ${command}`);
1800
- console.error("Run 'glancebar --help' for usage.");
1801
- process.exit(1);
1802
- }
1803
- }
1804
-
1805
- main().catch((err) => {
1806
- console.error("Error:", err.message);
1807
- process.exit(1);
1808
- });
1
+ #!/usr/bin/env bun
2
+ import { google } from "googleapis";
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
4
+ import { join, dirname } from "path";
5
+ import { createServer, Server } from "http";
6
+ import { createInterface } from "readline";
7
+ import { fileURLToPath } from "url";
8
+
9
+ // Get package version
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ const packageJson = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
13
+ const VERSION = packageJson.version;
14
+
15
+ // ============================================================================
16
+ // Configuration
17
+ // ============================================================================
18
+
19
+ interface Config {
20
+ accounts: string[]; // Legacy - for backwards compatibility
21
+ gmailAccounts: string[]; // Google accounts
22
+ zohoAccounts: ZohoAccount[]; // Zoho accounts
23
+ lookaheadHours: number;
24
+ showCalendarName: boolean;
25
+ countdownThresholdMinutes: number;
26
+ maxTitleLength: number;
27
+ waterReminderEnabled: boolean;
28
+ stretchReminderEnabled: boolean;
29
+ eyeReminderEnabled: boolean;
30
+ showCpuUsage: boolean;
31
+ showMemoryUsage: boolean;
32
+ showZohoTasks: boolean;
33
+ maxTasksToShow: number;
34
+ showUsageLimits: boolean;
35
+ show5HourLimit: boolean;
36
+ show7DayLimit: boolean;
37
+ show7DaySonnetLimit: boolean;
38
+ show5HourResets: boolean;
39
+ show7DayResets: boolean;
40
+ show7DaySonnetResets: boolean;
41
+ resetsTimeFormat: "relative" | "absolute";
42
+ usageLimitsCacheTTL: number; // In seconds, default 300 (5 min)
43
+ }
44
+
45
+ interface ZohoAccount {
46
+ email: string;
47
+ datacenter: string; // com, eu, in, com.au, com.cn, jp, zohocloud.ca
48
+ }
49
+
50
+ interface ClaudeCredentials {
51
+ claudeAiOauth: {
52
+ accessToken: string;
53
+ refreshToken: string;
54
+ expiresAt: number;
55
+ scopes: string[];
56
+ subscriptionType: string;
57
+ rateLimitTier: string;
58
+ };
59
+ }
60
+
61
+ interface UsageLimits {
62
+ five_hour: {
63
+ utilization: number; // Percentage (0-100)
64
+ resets_at: string; // ISO timestamp
65
+ };
66
+ seven_day: {
67
+ utilization: number; // Percentage (0-100)
68
+ resets_at: string; // ISO timestamp
69
+ };
70
+ seven_day_sonnet: {
71
+ utilization: number; // Percentage (0-100)
72
+ resets_at: string; // ISO timestamp
73
+ } | null; // null if not available in API response
74
+ }
75
+
76
+ interface UsageLimitsCache {
77
+ data: UsageLimits | null;
78
+ fetchedAt: number; // Unix timestamp
79
+ ttl: number; // Cache TTL in milliseconds (default: 5 minutes)
80
+ }
81
+
82
+ const COLORS: Record<string, string> = {
83
+ reset: "\x1b[0m",
84
+ red: "\x1b[31m",
85
+ green: "\x1b[32m",
86
+ yellow: "\x1b[33m",
87
+ blue: "\x1b[34m",
88
+ magenta: "\x1b[35m",
89
+ cyan: "\x1b[36m",
90
+ white: "\x1b[37m",
91
+ brightRed: "\x1b[91m",
92
+ brightGreen: "\x1b[92m",
93
+ brightYellow: "\x1b[93m",
94
+ brightBlue: "\x1b[94m",
95
+ brightMagenta: "\x1b[95m",
96
+ brightCyan: "\x1b[96m",
97
+ orange: "\x1b[38;5;208m",
98
+ pink: "\x1b[38;5;213m",
99
+ purple: "\x1b[38;5;141m",
100
+ };
101
+
102
+ const ACCOUNT_COLORS = ["cyan", "magenta", "brightGreen", "orange", "brightBlue", "pink", "yellow", "purple"];
103
+
104
+ const DEFAULT_CONFIG: Config = {
105
+ accounts: [], // Legacy
106
+ gmailAccounts: [],
107
+ zohoAccounts: [],
108
+ lookaheadHours: 8,
109
+ showCalendarName: true,
110
+ countdownThresholdMinutes: 60,
111
+ maxTitleLength: 120,
112
+ waterReminderEnabled: true,
113
+ stretchReminderEnabled: true,
114
+ eyeReminderEnabled: true,
115
+ showCpuUsage: false,
116
+ showMemoryUsage: false,
117
+ showZohoTasks: true,
118
+ maxTasksToShow: 3,
119
+ showUsageLimits: true,
120
+ show5HourLimit: true,
121
+ show7DayLimit: true,
122
+ show7DaySonnetLimit: true,
123
+ show5HourResets: false,
124
+ show7DayResets: false,
125
+ show7DaySonnetResets: false,
126
+ resetsTimeFormat: "relative",
127
+ usageLimitsCacheTTL: 120, // 2 minutes
128
+ };
129
+
130
+ const WATER_REMINDERS = [
131
+ "Stay hydrated! Drink some water",
132
+ "Time for a water break!",
133
+ "Hydration check! Grab some water",
134
+ "Your body needs water. Drink up!",
135
+ "Water break! Stay refreshed",
136
+ "Don't forget to drink water!",
137
+ "Hydrate yourself! Take a sip",
138
+ "Quick reminder: Drink water!",
139
+ ];
140
+
141
+ const STRETCH_REMINDERS = [
142
+ "Time to stretch! Stand up and move",
143
+ "Stretch break! Roll your shoulders",
144
+ "Stand up and stretch your legs",
145
+ "Posture check! Sit up straight",
146
+ "Take a quick stretch break",
147
+ "Move your body! Quick stretch",
148
+ "Stretch your neck and shoulders",
149
+ "Stand up! Your body will thank you",
150
+ ];
151
+
152
+ const EYE_REMINDERS = [
153
+ "Eye break! Look 20ft away for 20s",
154
+ "Rest your eyes - look at something distant",
155
+ "20-20-20: Look away from screen",
156
+ "Give your eyes a break!",
157
+ "Look away from the screen for a moment",
158
+ "Eye rest time! Focus on something far",
159
+ ];
160
+
161
+ function getConfigDir(): string {
162
+ const home = process.env.HOME || process.env.USERPROFILE || "";
163
+ return join(home, ".glancebar");
164
+ }
165
+
166
+ function getConfigPath(): string {
167
+ return join(getConfigDir(), "config.json");
168
+ }
169
+
170
+ function getTokensDir(): string {
171
+ return join(getConfigDir(), "tokens");
172
+ }
173
+
174
+ // ============================================================================
175
+ // Claude Usage Limits
176
+ // ============================================================================
177
+
178
+ function getClaudeCredentialsPath(): string {
179
+ const home = process.env.HOME || process.env.USERPROFILE || "";
180
+ return join(home, ".claude", ".credentials.json");
181
+ }
182
+
183
+ function loadClaudeCredentials(): ClaudeCredentials | null {
184
+ try {
185
+ const credPath = getClaudeCredentialsPath();
186
+ if (!existsSync(credPath)) return null;
187
+ const content = readFileSync(credPath, "utf-8");
188
+ return JSON.parse(content);
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ function getUsageLimitsCachePath(): string {
195
+ return join(getConfigDir(), "usage_limits_cache.json");
196
+ }
197
+
198
+ function loadUsageLimitsCache(): UsageLimitsCache {
199
+ try {
200
+ const cachePath = getUsageLimitsCachePath();
201
+ if (!existsSync(cachePath)) {
202
+ return { data: null, fetchedAt: 0, ttl: 120000 };
203
+ }
204
+ const content = readFileSync(cachePath, "utf-8");
205
+ return JSON.parse(content);
206
+ } catch {
207
+ return { data: null, fetchedAt: 0, ttl: 120000 };
208
+ }
209
+ }
210
+
211
+ function saveUsageLimitsCache(cache: UsageLimitsCache): void {
212
+ try {
213
+ ensureConfigDir();
214
+ const cachePath = getUsageLimitsCachePath();
215
+ writeFileSync(cachePath, JSON.stringify(cache, null, 2));
216
+ } catch {
217
+ // Silently fail if we can't save cache
218
+ }
219
+ }
220
+
221
+ async function fetchUsageLimitsFromAPI(): Promise<UsageLimits | null> {
222
+ try {
223
+ const creds = loadClaudeCredentials();
224
+ if (!creds?.claudeAiOauth?.accessToken) return null;
225
+
226
+ const controller = new AbortController();
227
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
228
+
229
+ const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
230
+ method: "GET",
231
+ headers: {
232
+ "Authorization": `Bearer ${creds.claudeAiOauth.accessToken}`,
233
+ "anthropic-beta": "oauth-2025-04-20",
234
+ },
235
+ signal: controller.signal,
236
+ });
237
+
238
+ clearTimeout(timeoutId);
239
+
240
+ if (!response.ok) {
241
+ return null;
242
+ }
243
+
244
+ const data = await response.json();
245
+
246
+ // Validate at least one limit exists
247
+ if (!data.five_hour && !data.seven_day && !data.seven_day_sonnet) {
248
+ return null;
249
+ }
250
+
251
+ return {
252
+ five_hour: data.five_hour ? {
253
+ utilization: data.five_hour.utilization || 0,
254
+ resets_at: data.five_hour.resets_at || "",
255
+ } : { utilization: 0, resets_at: "" },
256
+ seven_day: data.seven_day ? {
257
+ utilization: data.seven_day.utilization || 0,
258
+ resets_at: data.seven_day.resets_at || "",
259
+ } : { utilization: 0, resets_at: "" },
260
+ seven_day_sonnet: data.seven_day_sonnet ? {
261
+ utilization: data.seven_day_sonnet.utilization || 0,
262
+ resets_at: data.seven_day_sonnet.resets_at || "",
263
+ } : null,
264
+ };
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
269
+
270
+ async function getUsageLimits(config: Config): Promise<UsageLimits | null> {
271
+ const cache = loadUsageLimitsCache();
272
+ const now = Date.now();
273
+ const ttlMs = config.usageLimitsCacheTTL * 1000;
274
+
275
+ // Return cached data if fresh
276
+ if (cache.data && (now - cache.fetchedAt) < ttlMs) {
277
+ return cache.data;
278
+ }
279
+
280
+ // Cache is stale or missing, fetch from API
281
+ const freshData = await fetchUsageLimitsFromAPI();
282
+
283
+ // Update cache if fetch succeeded
284
+ if (freshData) {
285
+ saveUsageLimitsCache({
286
+ data: freshData,
287
+ fetchedAt: now,
288
+ ttl: ttlMs,
289
+ });
290
+ return freshData;
291
+ }
292
+
293
+ // Fetch failed - return stale cache if available
294
+ return cache.data || null;
295
+ }
296
+
297
+ // Helper function to format reset time
298
+ function formatResetTime(isoTimestamp: string, format: "relative" | "absolute"): string {
299
+ const resetDate = new Date(isoTimestamp);
300
+ const now = new Date();
301
+
302
+ if (format === "absolute") {
303
+ // Format as "Jan 30, 2:59 PM"
304
+ const dateStr = resetDate.toLocaleDateString([], { month: 'short', day: 'numeric' });
305
+ const timeStr = resetDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
306
+ return `${dateStr}, ${timeStr}`;
307
+ } else {
308
+ // Relative: "in 2h 15m" or "in 4d 10h"
309
+ const diffMs = resetDate.getTime() - now.getTime();
310
+ if (diffMs <= 0) return "now";
311
+
312
+ const diffMinutes = Math.floor(diffMs / 60000);
313
+ const hours = Math.floor(diffMinutes / 60);
314
+ const minutes = diffMinutes % 60;
315
+
316
+ // If over 24 hours, show days
317
+ if (hours >= 24) {
318
+ const days = Math.floor(hours / 24);
319
+ const remainingHours = hours % 24;
320
+ if (remainingHours === 0) return `in ${days}d`;
321
+ return `in ${days}d ${remainingHours}h`;
322
+ }
323
+
324
+ if (hours === 0) return `in ${minutes}m`;
325
+ if (minutes === 0) return `in ${hours}h`;
326
+ return `in ${hours}h${minutes}m`;
327
+ }
328
+ }
329
+
330
+ function formatUsageLimits(usageLimits: UsageLimits, config: Config): string | null {
331
+ const parts: string[] = [];
332
+
333
+ // Helper to format a single limit
334
+ const formatLimit = (label: string, utilization: number, resetsAt: string, showResets: boolean) => {
335
+ let color = COLORS.green;
336
+ if (utilization >= 80) color = COLORS.red;
337
+ else if (utilization >= 50) color = COLORS.yellow;
338
+
339
+ let text = `${label}: ${utilization.toFixed(0)}%`;
340
+ if (showResets && resetsAt) {
341
+ text += ` (${formatResetTime(resetsAt, config.resetsTimeFormat)})`;
342
+ }
343
+ return `${color}${text}${COLORS.reset}`;
344
+ };
345
+
346
+ // 5-hour limit
347
+ if (config.show5HourLimit) {
348
+ parts.push(formatLimit("5h", usageLimits.five_hour.utilization,
349
+ usageLimits.five_hour.resets_at, config.show5HourResets));
350
+ }
351
+
352
+ // 7-day limit
353
+ if (config.show7DayLimit) {
354
+ parts.push(formatLimit("7d", usageLimits.seven_day.utilization,
355
+ usageLimits.seven_day.resets_at, config.show7DayResets));
356
+ }
357
+
358
+ // 7-day sonnet limit
359
+ if (config.show7DaySonnetLimit && usageLimits.seven_day_sonnet) {
360
+ parts.push(formatLimit("7d-sonnet", usageLimits.seven_day_sonnet.utilization,
361
+ usageLimits.seven_day_sonnet.resets_at, config.show7DaySonnetResets));
362
+ }
363
+
364
+ return parts.length > 0 ? parts.join(" | ") : null;
365
+ }
366
+
367
+ function ensureConfigDir(): void {
368
+ const dir = getConfigDir();
369
+ if (!existsSync(dir)) {
370
+ mkdirSync(dir, { recursive: true });
371
+ }
372
+ }
373
+
374
+ function loadConfig(): Config {
375
+ const configPath = getConfigPath();
376
+ if (!existsSync(configPath)) {
377
+ return { ...DEFAULT_CONFIG };
378
+ }
379
+
380
+ try {
381
+ const content = readFileSync(configPath, "utf-8");
382
+ const userConfig = JSON.parse(content);
383
+ const config = { ...DEFAULT_CONFIG, ...userConfig };
384
+
385
+ // Migrate legacy accounts to gmailAccounts
386
+ if (config.accounts && config.accounts.length > 0 && (!config.gmailAccounts || config.gmailAccounts.length === 0)) {
387
+ config.gmailAccounts = [...config.accounts];
388
+ }
389
+
390
+ return config;
391
+ } catch {
392
+ return { ...DEFAULT_CONFIG };
393
+ }
394
+ }
395
+
396
+ function saveConfig(config: Config): void {
397
+ ensureConfigDir();
398
+ writeFileSync(getConfigPath(), JSON.stringify(config, null, 2));
399
+ }
400
+
401
+ // ============================================================================
402
+ // Google OAuth Authentication
403
+ // ============================================================================
404
+
405
+ const GOOGLE_SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"];
406
+ const REDIRECT_URI = "http://localhost:3000/callback";
407
+
408
+ interface GoogleCredentials {
409
+ installed?: { client_id: string; client_secret: string };
410
+ web?: { client_id: string; client_secret: string };
411
+ }
412
+
413
+ // ============================================================================
414
+ // Zoho OAuth Authentication
415
+ // ============================================================================
416
+
417
+ const ZOHO_SCOPES = [
418
+ "ZohoCalendar.calendar.READ",
419
+ "ZohoCalendar.event.READ",
420
+ "ZohoMail.tasks.READ",
421
+ ];
422
+ const ZOHO_REDIRECT_URI = "http://localhost:3000/callback";
423
+
424
+ // Zoho datacenter mappings
425
+ const ZOHO_DATACENTERS: Record<string, { accounts: string; calendar: string; mail: string }> = {
426
+ "com": { accounts: "https://accounts.zoho.com", calendar: "https://calendar.zoho.com", mail: "https://mail.zoho.com" },
427
+ "eu": { accounts: "https://accounts.zoho.eu", calendar: "https://calendar.zoho.eu", mail: "https://mail.zoho.eu" },
428
+ "in": { accounts: "https://accounts.zoho.in", calendar: "https://calendar.zoho.in", mail: "https://mail.zoho.in" },
429
+ "com.au": { accounts: "https://accounts.zoho.com.au", calendar: "https://calendar.zoho.com.au", mail: "https://mail.zoho.com.au" },
430
+ "com.cn": { accounts: "https://accounts.zoho.com.cn", calendar: "https://calendar.zoho.com.cn", mail: "https://mail.zoho.com.cn" },
431
+ "jp": { accounts: "https://accounts.zoho.jp", calendar: "https://calendar.zoho.jp", mail: "https://mail.zoho.jp" },
432
+ "zohocloud.ca": { accounts: "https://accounts.zohocloud.ca", calendar: "https://calendar.zohocloud.ca", mail: "https://mail.zohocloud.ca" },
433
+ };
434
+
435
+ interface ZohoCredentials {
436
+ client_id: string;
437
+ client_secret: string;
438
+ }
439
+
440
+ interface ZohoToken {
441
+ access_token: string;
442
+ refresh_token: string;
443
+ expires_at: number;
444
+ api_domain?: string;
445
+ }
446
+
447
+ // Google credentials
448
+ function getGoogleCredentialsPath(): string {
449
+ return join(getConfigDir(), "credentials.json");
450
+ }
451
+
452
+ function loadGoogleCredentials(): GoogleCredentials {
453
+ const credPath = getGoogleCredentialsPath();
454
+ if (!existsSync(credPath)) {
455
+ throw new Error(
456
+ `credentials.json not found at ${credPath}\n\nPlease download OAuth credentials from Google Cloud Console and save to:\n${credPath}\n\nRun 'glancebar setup' for detailed instructions.`
457
+ );
458
+ }
459
+ return JSON.parse(readFileSync(credPath, "utf-8"));
460
+ }
461
+
462
+ function getGoogleTokenPath(account: string): string {
463
+ const safeAccount = account.replace(/[^a-zA-Z0-9@.-]/g, "_");
464
+ return join(getTokensDir(), `google_${safeAccount}.json`);
465
+ }
466
+
467
+ // Legacy token path (for migration)
468
+ function getLegacyTokenPath(account: string): string {
469
+ const safeAccount = account.replace(/[^a-zA-Z0-9@.-]/g, "_");
470
+ return join(getTokensDir(), `${safeAccount}.json`);
471
+ }
472
+
473
+ // Zoho credentials
474
+ function getZohoCredentialsPath(): string {
475
+ return join(getConfigDir(), "zoho_credentials.json");
476
+ }
477
+
478
+ function loadZohoCredentials(): ZohoCredentials {
479
+ const credPath = getZohoCredentialsPath();
480
+ if (!existsSync(credPath)) {
481
+ throw new Error(
482
+ `zoho_credentials.json not found at ${credPath}\n\nPlease create OAuth credentials in Zoho API Console and save to:\n${credPath}\n\nRun 'glancebar setup' for detailed instructions.`
483
+ );
484
+ }
485
+ return JSON.parse(readFileSync(credPath, "utf-8"));
486
+ }
487
+
488
+ function getZohoTokenPath(account: string): string {
489
+ const safeAccount = account.replace(/[^a-zA-Z0-9@.-]/g, "_");
490
+ return join(getTokensDir(), `zoho_${safeAccount}.json`);
491
+ }
492
+
493
+ function createGoogleOAuth2Client(credentials: GoogleCredentials) {
494
+ const { client_id, client_secret } = credentials.installed || credentials.web!;
495
+ return new google.auth.OAuth2(client_id, client_secret, REDIRECT_URI);
496
+ }
497
+
498
+ function getGoogleAuthenticatedClient(account: string) {
499
+ const credentials = loadGoogleCredentials();
500
+ const oauth2Client = createGoogleOAuth2Client(credentials);
501
+
502
+ // Try new path first, then legacy path
503
+ let tokenPath = getGoogleTokenPath(account);
504
+ if (!existsSync(tokenPath)) {
505
+ const legacyPath = getLegacyTokenPath(account);
506
+ if (existsSync(legacyPath)) {
507
+ tokenPath = legacyPath;
508
+ } else {
509
+ return null;
510
+ }
511
+ }
512
+
513
+ const token = JSON.parse(readFileSync(tokenPath, "utf-8"));
514
+ oauth2Client.setCredentials(token);
515
+
516
+ oauth2Client.on("tokens", (tokens) => {
517
+ const currentToken = JSON.parse(readFileSync(tokenPath, "utf-8"));
518
+ const updatedToken = { ...currentToken, ...tokens };
519
+ writeFileSync(tokenPath, JSON.stringify(updatedToken, null, 2));
520
+ });
521
+
522
+ return oauth2Client;
523
+ }
524
+
525
+ async function authenticateGoogleAccount(account: string): Promise<void> {
526
+ const credentials = loadGoogleCredentials();
527
+ const oauth2Client = createGoogleOAuth2Client(credentials);
528
+
529
+ const authUrl = oauth2Client.generateAuthUrl({
530
+ access_type: "offline",
531
+ scope: GOOGLE_SCOPES,
532
+ prompt: "consent",
533
+ login_hint: account,
534
+ });
535
+
536
+ console.log(`\nAuthenticating: ${account}`);
537
+ console.log(`Opening browser...`);
538
+
539
+ const code = await startServerAndGetCode(authUrl);
540
+
541
+ console.log(`Exchanging code for tokens...`);
542
+
543
+ const { tokens } = await oauth2Client.getToken(code);
544
+ oauth2Client.setCredentials(tokens);
545
+
546
+ const tokensDir = getTokensDir();
547
+ if (!existsSync(tokensDir)) {
548
+ mkdirSync(tokensDir, { recursive: true });
549
+ }
550
+
551
+ const tokenPath = getGoogleTokenPath(account);
552
+ writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
553
+ console.log(`Token saved for ${account}`);
554
+ }
555
+
556
+ // ============================================================================
557
+ // Zoho OAuth Flow
558
+ // ============================================================================
559
+
560
+ async function authenticateZohoAccount(account: ZohoAccount): Promise<void> {
561
+ const credentials = loadZohoCredentials();
562
+ const dc = ZOHO_DATACENTERS[account.datacenter];
563
+
564
+ if (!dc) {
565
+ throw new Error(`Invalid datacenter: ${account.datacenter}. Valid options: ${Object.keys(ZOHO_DATACENTERS).join(", ")}`);
566
+ }
567
+
568
+ const params = new URLSearchParams({
569
+ response_type: "code",
570
+ client_id: credentials.client_id,
571
+ scope: ZOHO_SCOPES.join(","),
572
+ redirect_uri: ZOHO_REDIRECT_URI,
573
+ access_type: "offline",
574
+ prompt: "consent",
575
+ });
576
+
577
+ const authUrl = `${dc.accounts}/oauth/v2/auth?${params.toString()}`;
578
+
579
+ console.log(`\nAuthenticating Zoho: ${account.email}`);
580
+ console.log(`Datacenter: ${account.datacenter}`);
581
+ console.log(`Opening browser...`);
582
+
583
+ const code = await startServerAndGetCode(authUrl);
584
+
585
+ console.log(`Exchanging code for tokens...`);
586
+
587
+ // Exchange code for tokens
588
+ const tokenParams = new URLSearchParams({
589
+ grant_type: "authorization_code",
590
+ client_id: credentials.client_id,
591
+ client_secret: credentials.client_secret,
592
+ redirect_uri: ZOHO_REDIRECT_URI,
593
+ code: code,
594
+ });
595
+
596
+ const tokenResponse = await fetch(`${dc.accounts}/oauth/v2/token`, {
597
+ method: "POST",
598
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
599
+ body: tokenParams.toString(),
600
+ });
601
+
602
+ if (!tokenResponse.ok) {
603
+ const errorText = await tokenResponse.text();
604
+ throw new Error(`Failed to get Zoho tokens: ${errorText}`);
605
+ }
606
+
607
+ const tokenData = await tokenResponse.json();
608
+
609
+ const token: ZohoToken = {
610
+ access_token: tokenData.access_token,
611
+ refresh_token: tokenData.refresh_token,
612
+ expires_at: Date.now() + (tokenData.expires_in * 1000),
613
+ api_domain: tokenData.api_domain || dc.calendar,
614
+ };
615
+
616
+ const tokensDir = getTokensDir();
617
+ if (!existsSync(tokensDir)) {
618
+ mkdirSync(tokensDir, { recursive: true });
619
+ }
620
+
621
+ const tokenPath = getZohoTokenPath(account.email);
622
+ writeFileSync(tokenPath, JSON.stringify(token, null, 2));
623
+ console.log(`Token saved for ${account.email}`);
624
+ }
625
+
626
+ async function refreshZohoToken(account: ZohoAccount): Promise<ZohoToken | null> {
627
+ const tokenPath = getZohoTokenPath(account.email);
628
+ if (!existsSync(tokenPath)) return null;
629
+
630
+ const token: ZohoToken = JSON.parse(readFileSync(tokenPath, "utf-8"));
631
+
632
+ // Check if token is still valid (with 5 minute buffer)
633
+ if (token.expires_at > Date.now() + 300000) {
634
+ return token;
635
+ }
636
+
637
+ // Refresh the token
638
+ try {
639
+ const credentials = loadZohoCredentials();
640
+ const dc = ZOHO_DATACENTERS[account.datacenter];
641
+
642
+ const params = new URLSearchParams({
643
+ grant_type: "refresh_token",
644
+ client_id: credentials.client_id,
645
+ client_secret: credentials.client_secret,
646
+ refresh_token: token.refresh_token,
647
+ });
648
+
649
+ const response = await fetch(`${dc.accounts}/oauth/v2/token`, {
650
+ method: "POST",
651
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
652
+ body: params.toString(),
653
+ });
654
+
655
+ if (!response.ok) return null;
656
+
657
+ const data = await response.json();
658
+ const updatedToken: ZohoToken = {
659
+ ...token,
660
+ access_token: data.access_token,
661
+ expires_at: Date.now() + (data.expires_in * 1000),
662
+ };
663
+
664
+ writeFileSync(tokenPath, JSON.stringify(updatedToken, null, 2));
665
+ return updatedToken;
666
+ } catch {
667
+ return null;
668
+ }
669
+ }
670
+
671
+ function getZohoAuthenticatedToken(account: ZohoAccount): ZohoToken | null {
672
+ const tokenPath = getZohoTokenPath(account.email);
673
+ if (!existsSync(tokenPath)) return null;
674
+ return JSON.parse(readFileSync(tokenPath, "utf-8"));
675
+ }
676
+
677
+ function startServerAndGetCode(authUrl: string): Promise<string> {
678
+ return new Promise((resolve, reject) => {
679
+ let server: Server;
680
+
681
+ server = createServer(async (req, res) => {
682
+ const url = new URL(req.url!, `http://localhost:3000`);
683
+
684
+ if (!url.pathname.startsWith("/callback")) {
685
+ res.writeHead(404);
686
+ res.end("Not found");
687
+ return;
688
+ }
689
+
690
+ const code = url.searchParams.get("code");
691
+ const error = url.searchParams.get("error");
692
+
693
+ if (error) {
694
+ res.writeHead(400, { "Content-Type": "text/html" });
695
+ res.end(`<html><body><h1>Authentication failed</h1><p>Error: ${error}</p></body></html>`);
696
+ server.close();
697
+ reject(new Error(error));
698
+ return;
699
+ }
700
+
701
+ if (code) {
702
+ res.writeHead(200, { "Content-Type": "text/html" });
703
+ res.end(`
704
+ <html>
705
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee;">
706
+ <div style="text-align: center;">
707
+ <h1 style="color: #4ade80;">Authentication Successful!</h1>
708
+ <p>You can close this window and return to the terminal.</p>
709
+ </div>
710
+ </body>
711
+ </html>
712
+ `);
713
+
714
+ setTimeout(() => {
715
+ server.close(() => resolve(code));
716
+ }, 500);
717
+ } else {
718
+ res.writeHead(400, { "Content-Type": "text/html" });
719
+ res.end("<html><body><h1>Authentication failed</h1><p>No code received.</p></body></html>");
720
+ }
721
+ });
722
+
723
+ server.listen(3000, () => openBrowser(authUrl));
724
+
725
+ server.on("error", (err: NodeJS.ErrnoException) => {
726
+ if (err.code === "EADDRINUSE") {
727
+ reject(new Error("Port 3000 is already in use. Please close any application using it and try again."));
728
+ } else {
729
+ reject(err);
730
+ }
731
+ });
732
+
733
+ setTimeout(() => {
734
+ server.close();
735
+ reject(new Error("Authentication timeout (5 minutes)"));
736
+ }, 300000);
737
+ });
738
+ }
739
+
740
+ function openBrowser(url: string) {
741
+ const { exec } = require("child_process");
742
+ const platform = process.platform;
743
+
744
+ let command: string;
745
+ if (platform === "win32") {
746
+ command = `start "" "${url}"`;
747
+ } else if (platform === "darwin") {
748
+ command = `open "${url}"`;
749
+ } else {
750
+ command = `xdg-open "${url}"`;
751
+ }
752
+
753
+ exec(command, (err: Error | null) => {
754
+ if (err) {
755
+ console.log(`\nCould not open browser automatically.`);
756
+ console.log(`Please open this URL manually:\n${url}\n`);
757
+ }
758
+ });
759
+ }
760
+
761
+ // ============================================================================
762
+ // Calendar
763
+ // ============================================================================
764
+
765
+ interface CalendarEvent {
766
+ id: string;
767
+ title: string;
768
+ start: Date;
769
+ end: Date;
770
+ isAllDay: boolean;
771
+ account: string;
772
+ accountEmail: string;
773
+ accountIndex: number;
774
+ provider: "google" | "zoho";
775
+ }
776
+
777
+ // Get all accounts combined for indexing
778
+ function getAllAccounts(config: Config): string[] {
779
+ const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
780
+ const zohoEmails = config.zohoAccounts.map(z => z.email);
781
+ return [...gmailAccounts, ...zohoEmails];
782
+ }
783
+
784
+ async function getGoogleEvents(config: Config, now: Date, timeMax: Date): Promise<CalendarEvent[]> {
785
+ const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
786
+ const allAccounts = getAllAccounts(config);
787
+
788
+ const eventPromises = gmailAccounts.map(async (account) => {
789
+ const accountIndex = allAccounts.indexOf(account);
790
+ try {
791
+ const auth = getGoogleAuthenticatedClient(account);
792
+ if (!auth) return [];
793
+
794
+ const calendar = google.calendar({ version: "v3", auth });
795
+
796
+ const response = await calendar.events.list({
797
+ calendarId: "primary",
798
+ timeMin: now.toISOString(),
799
+ timeMax: timeMax.toISOString(),
800
+ maxResults: 10,
801
+ singleEvents: true,
802
+ orderBy: "startTime",
803
+ });
804
+
805
+ const events = response.data.items || [];
806
+ return events.map((event) => {
807
+ const isAllDay = !event.start?.dateTime;
808
+ let start: Date, end: Date;
809
+
810
+ if (isAllDay) {
811
+ start = new Date(event.start?.date + "T00:00:00");
812
+ end = new Date(event.end?.date + "T00:00:00");
813
+ } else {
814
+ start = new Date(event.start?.dateTime!);
815
+ end = new Date(event.end?.dateTime!);
816
+ }
817
+
818
+ return {
819
+ id: event.id || "",
820
+ title: event.summary || "(No title)",
821
+ start,
822
+ end,
823
+ isAllDay,
824
+ account: extractAccountName(account),
825
+ accountEmail: account,
826
+ accountIndex,
827
+ provider: "google" as const,
828
+ };
829
+ });
830
+ } catch {
831
+ return [];
832
+ }
833
+ });
834
+
835
+ const results = await Promise.all(eventPromises);
836
+ return results.flat();
837
+ }
838
+
839
+ async function getZohoEvents(config: Config, now: Date, timeMax: Date): Promise<CalendarEvent[]> {
840
+ if (!config.zohoAccounts || config.zohoAccounts.length === 0) return [];
841
+
842
+ const allAccounts = getAllAccounts(config);
843
+ const gmailCount = (config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts).length;
844
+
845
+ const eventPromises = config.zohoAccounts.map(async (account, idx) => {
846
+ const accountIndex = gmailCount + idx;
847
+ try {
848
+ const token = await refreshZohoToken(account);
849
+ if (!token) return [];
850
+
851
+ const dc = ZOHO_DATACENTERS[account.datacenter];
852
+ // Always use the calendar-specific API domain, not the generic api_domain
853
+ const apiBase = dc.calendar;
854
+
855
+ // First get list of calendars
856
+ const calendarsResponse = await fetch(`${apiBase}/api/v1/calendars?category=own`, {
857
+ headers: {
858
+ Authorization: `Zoho-oauthtoken ${token.access_token}`,
859
+ },
860
+ });
861
+
862
+ if (!calendarsResponse.ok) return [];
863
+
864
+ const calendarsData = await calendarsResponse.json();
865
+ const calendars = calendarsData.calendars || [];
866
+
867
+ if (calendars.length === 0) return [];
868
+
869
+ // Use the first (primary) calendar
870
+ const primaryCalendar = calendars.find((c: any) => c.isdefault) || calendars[0];
871
+ const calendarUid = primaryCalendar.uid;
872
+
873
+ // Format dates for Zoho API (yyyyMMdd'T'HHmmss'Z')
874
+ const formatZohoDate = (date: Date): string => {
875
+ return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
876
+ };
877
+
878
+ const range = JSON.stringify({
879
+ start: formatZohoDate(now),
880
+ end: formatZohoDate(timeMax),
881
+ });
882
+
883
+ const eventsResponse = await fetch(
884
+ `${apiBase}/api/v1/calendars/${encodeURIComponent(calendarUid)}/events?range=${encodeURIComponent(range)}`,
885
+ {
886
+ headers: {
887
+ Authorization: `Zoho-oauthtoken ${token.access_token}`,
888
+ },
889
+ }
890
+ );
891
+
892
+ if (!eventsResponse.ok) return [];
893
+
894
+ const eventsData = await eventsResponse.json();
895
+ const events = eventsData.events || [];
896
+
897
+ return events.map((event: any) => {
898
+ const isAllDay = event.isallday === true;
899
+ let start: Date, end: Date;
900
+
901
+ // Parse Zoho date format: "20260109T163000+0530" or "20260109T163000Z"
902
+ const parseZohoDate = (dateStr: string): Date => {
903
+ // Format: YYYYMMDDTHHmmss+HHMM or YYYYMMDDTHHmmssZ
904
+ const match = dateStr.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})([Z]|([+-])(\d{2})(\d{2}))?$/);
905
+ if (match) {
906
+ const [, year, month, day, hour, min, sec, tz, sign, tzHour, tzMin] = match;
907
+ if (tz === "Z") {
908
+ return new Date(Date.UTC(+year, +month - 1, +day, +hour, +min, +sec));
909
+ } else if (sign && tzHour && tzMin) {
910
+ const offsetMinutes = (+tzHour * 60 + +tzMin) * (sign === "+" ? -1 : 1);
911
+ const utc = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
912
+ return new Date(utc + offsetMinutes * 60000);
913
+ }
914
+ // No timezone, assume local
915
+ return new Date(+year, +month - 1, +day, +hour, +min, +sec);
916
+ }
917
+ // Fallback to standard parsing
918
+ return new Date(dateStr);
919
+ };
920
+
921
+ if (event.dateandtime) {
922
+ start = parseZohoDate(event.dateandtime.start);
923
+ end = parseZohoDate(event.dateandtime.end);
924
+ } else {
925
+ start = parseZohoDate(event.start);
926
+ end = parseZohoDate(event.end);
927
+ }
928
+
929
+ return {
930
+ id: event.uid || "",
931
+ title: event.title || "(No title)",
932
+ start,
933
+ end,
934
+ isAllDay,
935
+ account: extractAccountName(account.email),
936
+ accountEmail: account.email,
937
+ accountIndex,
938
+ provider: "zoho" as const,
939
+ };
940
+ });
941
+ } catch {
942
+ return [];
943
+ }
944
+ });
945
+
946
+ const results = await Promise.all(eventPromises);
947
+ return results.flat();
948
+ }
949
+
950
+ async function getUpcomingEvents(config: Config): Promise<CalendarEvent[]> {
951
+ const now = new Date();
952
+ const timeMax = new Date(now.getTime() + config.lookaheadHours * 60 * 60 * 1000);
953
+
954
+ // Fetch from both providers in parallel
955
+ const [googleEvents, zohoEvents] = await Promise.all([
956
+ getGoogleEvents(config, now, timeMax),
957
+ getZohoEvents(config, now, timeMax),
958
+ ]);
959
+
960
+ const allEvents = [...googleEvents, ...zohoEvents];
961
+ allEvents.sort((a, b) => a.start.getTime() - b.start.getTime());
962
+ return allEvents;
963
+ }
964
+
965
+ // ============================================================================
966
+ // Zoho Tasks
967
+ // ============================================================================
968
+
969
+ interface ZohoTask {
970
+ id: string;
971
+ title: string;
972
+ description: string;
973
+ dueDate: Date | null;
974
+ priority: "High" | "Normal" | "Low";
975
+ status: string;
976
+ isOverdue: boolean;
977
+ }
978
+
979
+ async function getZohoTasks(config: Config): Promise<ZohoTask[]> {
980
+ if (!config.zohoAccounts || config.zohoAccounts.length === 0) return [];
981
+ if (!config.showZohoTasks) return [];
982
+
983
+ const allTasks: ZohoTask[] = [];
984
+
985
+ for (const account of config.zohoAccounts) {
986
+ try {
987
+ const token = await refreshZohoToken(account);
988
+ if (!token) continue;
989
+
990
+ const dc = ZOHO_DATACENTERS[account.datacenter];
991
+ const mailBase = dc.mail;
992
+
993
+ // Fetch tasks assigned to user
994
+ const response = await fetch(
995
+ `${mailBase}/api/tasks/?view=assignedtome&action=view&limit=10&from=0`,
996
+ {
997
+ headers: {
998
+ Authorization: `Zoho-oauthtoken ${token.access_token}`,
999
+ Accept: "application/json",
1000
+ },
1001
+ }
1002
+ );
1003
+
1004
+ if (!response.ok) continue;
1005
+
1006
+ const data = await response.json();
1007
+ const tasks = data.data?.tasks || [];
1008
+
1009
+ const now = new Date();
1010
+
1011
+ for (const task of tasks) {
1012
+ // Skip completed tasks
1013
+ if (task.status === "Completed" || task.status === "completed") continue;
1014
+
1015
+ let dueDate: Date | null = null;
1016
+ let isOverdue = false;
1017
+
1018
+ if (task.dueDate) {
1019
+ // Parse DD/MM/YYYY format
1020
+ const parts = task.dueDate.split("/");
1021
+ if (parts.length === 3) {
1022
+ dueDate = new Date(+parts[2], +parts[1] - 1, +parts[0]);
1023
+ isOverdue = dueDate < now;
1024
+ }
1025
+ }
1026
+
1027
+ allTasks.push({
1028
+ id: task.id || "",
1029
+ title: task.title || "(No title)",
1030
+ description: task.description || "",
1031
+ dueDate,
1032
+ priority: task.priority || "Normal",
1033
+ status: task.status || "Open",
1034
+ isOverdue,
1035
+ });
1036
+ }
1037
+ } catch {
1038
+ // Silently continue on error
1039
+ }
1040
+ }
1041
+
1042
+ // Sort: overdue first, then by due date (soonest first), then by priority
1043
+ allTasks.sort((a, b) => {
1044
+ // Overdue tasks first
1045
+ if (a.isOverdue && !b.isOverdue) return -1;
1046
+ if (!a.isOverdue && b.isOverdue) return 1;
1047
+
1048
+ // Then by due date (null dates go last)
1049
+ if (a.dueDate && b.dueDate) {
1050
+ return a.dueDate.getTime() - b.dueDate.getTime();
1051
+ }
1052
+ if (a.dueDate && !b.dueDate) return -1;
1053
+ if (!a.dueDate && b.dueDate) return 1;
1054
+
1055
+ // Then by priority
1056
+ const priorityOrder = { High: 0, Normal: 1, Low: 2 };
1057
+ return (priorityOrder[a.priority] || 1) - (priorityOrder[b.priority] || 1);
1058
+ });
1059
+
1060
+ return allTasks.slice(0, config.maxTasksToShow);
1061
+ }
1062
+
1063
+ function formatTasks(tasks: ZohoTask[]): string | null {
1064
+ if (tasks.length === 0) return null;
1065
+
1066
+ const formatted = tasks.map((task) => {
1067
+ const title = task.title.length > 25 ? task.title.slice(0, 24) + "" : task.title;
1068
+
1069
+ if (task.isOverdue) {
1070
+ return `${COLORS.red}${title}${COLORS.reset}`;
1071
+ } else if (task.priority === "High") {
1072
+ return `${COLORS.yellow}${title}${COLORS.reset}`;
1073
+ } else {
1074
+ return `${COLORS.white}${title}${COLORS.reset}`;
1075
+ }
1076
+ });
1077
+
1078
+ return `${COLORS.cyan}Tasks:${COLORS.reset} ${formatted.join(", ")}`;
1079
+ }
1080
+
1081
+ function extractAccountName(email: string): string {
1082
+ const atIndex = email.indexOf("@");
1083
+ if (atIndex === -1) return email;
1084
+
1085
+ const domain = email.slice(atIndex + 1);
1086
+ if (domain === "gmail.com") {
1087
+ return email.slice(0, atIndex);
1088
+ }
1089
+
1090
+ return domain.split(".")[0];
1091
+ }
1092
+
1093
+ function getCurrentOrNextEvent(events: CalendarEvent[]): CalendarEvent | null {
1094
+ const now = new Date();
1095
+
1096
+ for (const event of events) {
1097
+ if (event.start <= now && event.end > now) return event;
1098
+ }
1099
+
1100
+ for (const event of events) {
1101
+ if (event.start > now) return event;
1102
+ }
1103
+
1104
+ return null;
1105
+ }
1106
+
1107
+ // ============================================================================
1108
+ // Formatter
1109
+ // ============================================================================
1110
+
1111
+ function formatEvent(event: CalendarEvent, config: Config): string {
1112
+ const now = new Date();
1113
+ const isHappening = event.start <= now && event.end > now;
1114
+ const minutesUntil = Math.round((event.start.getTime() - now.getTime()) / 60000);
1115
+
1116
+ let timeStr: string;
1117
+ if (isHappening) {
1118
+ timeStr = "Now";
1119
+ } else if (minutesUntil <= config.countdownThresholdMinutes && minutesUntil > 0) {
1120
+ timeStr = formatCountdown(minutesUntil);
1121
+ } else {
1122
+ timeStr = formatTime(event.start);
1123
+ }
1124
+
1125
+ const title = event.title.length > config.maxTitleLength
1126
+ ? event.title.slice(0, config.maxTitleLength - 1) + "…"
1127
+ : event.title;
1128
+
1129
+ const colorName = ACCOUNT_COLORS[event.accountIndex % ACCOUNT_COLORS.length];
1130
+ const color = COLORS[colorName] || COLORS.white;
1131
+
1132
+ if (config.showCalendarName) {
1133
+ return `${color}${timeStr}: ${title} (${event.account})${COLORS.reset}`;
1134
+ }
1135
+ return `${color}${timeStr}: ${title}${COLORS.reset}`;
1136
+ }
1137
+
1138
+ function formatCountdown(minutes: number): string {
1139
+ if (minutes < 60) return `In ${minutes}m`;
1140
+ const hours = Math.floor(minutes / 60);
1141
+ const mins = minutes % 60;
1142
+ return mins === 0 ? `In ${hours}h` : `In ${hours}h${mins}m`;
1143
+ }
1144
+
1145
+ function getMeetingWarning(event: CalendarEvent | null): string | null {
1146
+ if (!event) return null;
1147
+
1148
+ const now = new Date();
1149
+ const minutesUntil = Math.round((event.start.getTime() - now.getTime()) / 60000);
1150
+
1151
+ // Warning when meeting is 5 minutes or less away
1152
+ if (minutesUntil > 0 && minutesUntil <= 5) {
1153
+ return `${COLORS.brightRed}Meeting in ${minutesUntil}m - wrap up!${COLORS.reset}`;
1154
+ }
1155
+
1156
+ return null;
1157
+ }
1158
+
1159
+ // ============================================================================
1160
+ // System Stats
1161
+ // ============================================================================
1162
+
1163
+ function getCpuUsage(): string | null {
1164
+ try {
1165
+ const os = require("os");
1166
+ const cpus = os.cpus();
1167
+
1168
+ let totalIdle = 0;
1169
+ let totalTick = 0;
1170
+
1171
+ for (const cpu of cpus) {
1172
+ for (const type in cpu.times) {
1173
+ totalTick += cpu.times[type as keyof typeof cpu.times];
1174
+ }
1175
+ totalIdle += cpu.times.idle;
1176
+ }
1177
+
1178
+ const usage = Math.round(100 - (totalIdle / totalTick) * 100);
1179
+
1180
+ // Color based on usage
1181
+ let color = COLORS.green;
1182
+ if (usage >= 80) color = COLORS.red;
1183
+ else if (usage >= 50) color = COLORS.yellow;
1184
+
1185
+ return `${color}CPU ${usage}%${COLORS.reset}`;
1186
+ } catch {
1187
+ return null;
1188
+ }
1189
+ }
1190
+
1191
+ function getMemoryUsage(): string | null {
1192
+ try {
1193
+ const os = require("os");
1194
+ const totalMem = os.totalmem();
1195
+
1196
+ // Read MemAvailable from /proc/meminfo (Linux only)
1197
+ // MemAvailable accounts for reclaimable cache/buffers
1198
+ let availableMem = os.freemem(); // fallback for non-Linux
1199
+
1200
+ if (process.platform === "linux") {
1201
+ try {
1202
+ const meminfo = readFileSync("/proc/meminfo", "utf-8");
1203
+ const match = meminfo.match(/MemAvailable:\s+(\d+)\s+kB/);
1204
+ if (match) {
1205
+ availableMem = parseInt(match[1], 10) * 1024; // Convert kB to bytes
1206
+ }
1207
+ } catch {
1208
+ // Fall back to freemem if /proc/meminfo is unavailable
1209
+ }
1210
+ }
1211
+
1212
+ const usedMem = totalMem - availableMem;
1213
+ const usagePercent = Math.round((usedMem / totalMem) * 100);
1214
+
1215
+ // Format used memory
1216
+ const usedGB = (usedMem / (1024 * 1024 * 1024)).toFixed(1);
1217
+ const totalGB = (totalMem / (1024 * 1024 * 1024)).toFixed(1);
1218
+
1219
+ // Color based on usage
1220
+ let color = COLORS.green;
1221
+ if (usagePercent >= 80) color = COLORS.red;
1222
+ else if (usagePercent >= 50) color = COLORS.yellow;
1223
+
1224
+ return `${color}Mem ${usedGB}/${totalGB}GB${COLORS.reset}`;
1225
+ } catch {
1226
+ return null;
1227
+ }
1228
+ }
1229
+
1230
+ function formatTime(date: Date): string {
1231
+ const hours = date.getHours();
1232
+ const minutes = date.getMinutes();
1233
+ const isPM = hours >= 12;
1234
+ const hour12 = hours % 12 || 12;
1235
+ return `${hour12}:${minutes.toString().padStart(2, "0")} ${isPM ? "PM" : "AM"}`;
1236
+ }
1237
+
1238
+ // ============================================================================
1239
+ // CLI Commands
1240
+ // ============================================================================
1241
+
1242
+ function printHelp() {
1243
+ console.log(`
1244
+ glancebar - A customizable statusline for Claude Code
1245
+
1246
+ Display calendar events, tasks, and more at a glance.
1247
+
1248
+ Usage:
1249
+ glancebar Output statusline (for Claude Code)
1250
+ glancebar auth Authenticate all configured accounts
1251
+ glancebar auth --add <email> Add and authenticate a new account (prompts for provider)
1252
+ glancebar auth --remove <email> Remove an account
1253
+ glancebar auth --list List all configured accounts
1254
+ glancebar config Show current configuration
1255
+ glancebar config --lookahead <hours> Set lookahead hours (default: 8)
1256
+ glancebar config --countdown-threshold <mins> Set countdown threshold in minutes (default: 60)
1257
+ glancebar config --max-title <length> Set max title length (default: 120)
1258
+ glancebar config --show-calendar <true|false> Show calendar name (default: true)
1259
+ glancebar config --water-reminder <true|false> Enable/disable water reminders (default: true)
1260
+ glancebar config --stretch-reminder <true|false> Enable/disable stretch reminders (default: true)
1261
+ glancebar config --eye-reminder <true|false> Enable/disable eye break reminders (default: true)
1262
+ glancebar config --cpu-usage <true|false> Show CPU usage (default: false)
1263
+ glancebar config --memory-usage <true|false> Show memory usage (default: false)
1264
+ glancebar config --zoho-tasks <true|false> Show Zoho tasks (default: true)
1265
+ glancebar config --max-tasks <number> Max tasks to show (default: 3)
1266
+ glancebar config --show-usage-limits <true|false> Show usage limits from API (default: true)
1267
+ glancebar config --show-5hour-limit <true|false> Show 5-hour window utilization (default: true)
1268
+ glancebar config --show-7day-limit <true|false> Show 7-day window utilization (default: true)
1269
+ glancebar config --show-7day-sonnet-limit <true|false> Show 7-day Sonnet limit (default: true)
1270
+ glancebar config --show-5hour-resets <true|false> Show 5-hour reset time (default: false)
1271
+ glancebar config --show-7day-resets <true|false> Show 7-day reset time (default: false)
1272
+ glancebar config --show-7day-sonnet-resets <true|false> Show 7-day Sonnet reset time (default: false)
1273
+ glancebar config --resets-time-format <relative|absolute> Reset time display format (default: relative)
1274
+ glancebar config --usage-cache-ttl <seconds> API cache TTL in seconds (default: 120)
1275
+ glancebar config --reset Reset to default configuration
1276
+ glancebar setup Show setup instructions
1277
+ glancebar --version Show version
1278
+
1279
+ Examples:
1280
+ glancebar auth --add user@gmail.com # Will prompt for Google or Zoho
1281
+ glancebar auth --add user@zoho.com # Will prompt for Google or Zoho
1282
+ glancebar config --lookahead 12
1283
+ glancebar config --stretch-reminder false
1284
+
1285
+ Config location: ${getConfigDir()}
1286
+ `);
1287
+ }
1288
+
1289
+ function printSetup() {
1290
+ console.log(`
1291
+ Glancebar - Setup Instructions
1292
+ ==============================
1293
+
1294
+ GOOGLE CALENDAR SETUP
1295
+ ---------------------
1296
+
1297
+ Step 1: Create Google Cloud Project
1298
+ - Go to https://console.cloud.google.com/
1299
+ - Create a new project or select existing one
1300
+
1301
+ Step 2: Enable Google Calendar API
1302
+ - Go to "APIs & Services" > "Library"
1303
+ - Search for "Google Calendar API" and enable it
1304
+
1305
+ Step 3: Create OAuth Credentials
1306
+ - Go to "APIs & Services" > "Credentials"
1307
+ - Click "Create Credentials" > "OAuth client ID"
1308
+ - Select "Desktop app" as application type
1309
+ - Download the JSON file
1310
+
1311
+ Step 4: Save credentials
1312
+ - Rename downloaded file to "credentials.json"
1313
+ - Save it to: ${getGoogleCredentialsPath()}
1314
+
1315
+ Step 5: Add redirect URI
1316
+ - Edit credentials.json and ensure redirect_uris contains:
1317
+ "redirect_uris": ["http://localhost:3000/callback"]
1318
+
1319
+ ZOHO CALENDAR SETUP
1320
+ -------------------
1321
+
1322
+ Step 1: Register Application
1323
+ - Go to https://api-console.zoho.com/
1324
+ - Click "Add Client" > "Server-based Applications"
1325
+
1326
+ Step 2: Configure Client
1327
+ - Set Authorized Redirect URI: http://localhost:3000/callback
1328
+ - Note your Client ID and Client Secret
1329
+
1330
+ Step 3: Save credentials
1331
+ - Create file: ${getZohoCredentialsPath()}
1332
+ - Add content:
1333
+ {
1334
+ "client_id": "YOUR_CLIENT_ID",
1335
+ "client_secret": "YOUR_CLIENT_SECRET"
1336
+ }
1337
+
1338
+ ADDING ACCOUNTS
1339
+ ---------------
1340
+
1341
+ glancebar auth --add your-email@gmail.com
1342
+ # Select "Google" or "Zoho" when prompted
1343
+ # For Zoho, select your datacenter region
1344
+
1345
+ CONFIGURE CLAUDE CODE
1346
+ ---------------------
1347
+
1348
+ Update ~/.claude/settings.json:
1349
+ {
1350
+ "statusLine": {
1351
+ "type": "command",
1352
+ "command": "bunx @naarang/glancebar",
1353
+ "padding": 0
1354
+ }
1355
+ }
1356
+
1357
+ For more info: https://github.com/vishal-android-freak/glancebar
1358
+ `);
1359
+ }
1360
+
1361
+ async function handleAuth(args: string[]) {
1362
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1363
+ const prompt = (q: string): Promise<string> => new Promise((r) => rl.question(q, r));
1364
+
1365
+ // Handle --list
1366
+ if (args.includes("--list")) {
1367
+ const config = loadConfig();
1368
+ const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
1369
+ const hasAny = gmailAccounts.length > 0 || config.zohoAccounts.length > 0;
1370
+
1371
+ if (!hasAny) {
1372
+ console.log("No accounts configured.");
1373
+ } else {
1374
+ if (gmailAccounts.length > 0) {
1375
+ console.log("\nGoogle Calendar accounts:");
1376
+ gmailAccounts.forEach((acc, i) => {
1377
+ let tokenPath = getGoogleTokenPath(acc);
1378
+ if (!existsSync(tokenPath)) {
1379
+ tokenPath = getLegacyTokenPath(acc);
1380
+ }
1381
+ const status = existsSync(tokenPath) ? "authenticated" : "not authenticated";
1382
+ console.log(` ${i + 1}. ${acc} (${status})`);
1383
+ });
1384
+ }
1385
+
1386
+ if (config.zohoAccounts.length > 0) {
1387
+ console.log("\nZoho Calendar accounts:");
1388
+ config.zohoAccounts.forEach((acc, i) => {
1389
+ const tokenPath = getZohoTokenPath(acc.email);
1390
+ const status = existsSync(tokenPath) ? "authenticated" : "not authenticated";
1391
+ console.log(` ${i + 1}. ${acc.email} [${acc.datacenter}] (${status})`);
1392
+ });
1393
+ }
1394
+ }
1395
+ rl.close();
1396
+ return;
1397
+ }
1398
+
1399
+ // Handle --add
1400
+ const addIndex = args.indexOf("--add");
1401
+ if (addIndex !== -1) {
1402
+ const email = args[addIndex + 1];
1403
+ if (!email || email.startsWith("--")) {
1404
+ console.error("Error: Please provide an email address after --add");
1405
+ rl.close();
1406
+ process.exit(1);
1407
+ }
1408
+
1409
+ if (!email.includes("@")) {
1410
+ console.error("Error: Invalid email address");
1411
+ rl.close();
1412
+ process.exit(1);
1413
+ }
1414
+
1415
+ // Prompt for provider
1416
+ console.log("\nSelect calendar provider:");
1417
+ console.log(" 1. Google Calendar");
1418
+ console.log(" 2. Zoho Calendar");
1419
+ const providerChoice = await prompt("\nEnter choice (1 or 2): ");
1420
+
1421
+ const config = loadConfig();
1422
+
1423
+ if (providerChoice === "1") {
1424
+ // Google Calendar
1425
+ const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
1426
+ if (gmailAccounts.includes(email)) {
1427
+ console.log(`\nGoogle account ${email} already exists. Re-authenticating...`);
1428
+ } else {
1429
+ if (config.gmailAccounts.length === 0 && config.accounts.length > 0) {
1430
+ config.gmailAccounts = [...config.accounts];
1431
+ }
1432
+ config.gmailAccounts.push(email);
1433
+ saveConfig(config);
1434
+ console.log(`\nAdded ${email} to Google accounts.`);
1435
+ }
1436
+
1437
+ await authenticateGoogleAccount(email);
1438
+ console.log("\nDone!");
1439
+ } else if (providerChoice === "2") {
1440
+ // Zoho Calendar
1441
+ console.log("\nSelect Zoho datacenter:");
1442
+ console.log(" 1. com - United States");
1443
+ console.log(" 2. eu - Europe");
1444
+ console.log(" 3. in - India");
1445
+ console.log(" 4. com.au - Australia");
1446
+ console.log(" 5. com.cn - China");
1447
+ console.log(" 6. jp - Japan");
1448
+ console.log(" 7. zohocloud.ca - Canada");
1449
+
1450
+ const dcChoice = await prompt("\nEnter choice (1-7): ");
1451
+ const dcMap: Record<string, string> = {
1452
+ "1": "com",
1453
+ "2": "eu",
1454
+ "3": "in",
1455
+ "4": "com.au",
1456
+ "5": "com.cn",
1457
+ "6": "jp",
1458
+ "7": "zohocloud.ca",
1459
+ };
1460
+
1461
+ const datacenter = dcMap[dcChoice];
1462
+ if (!datacenter) {
1463
+ console.error("Error: Invalid datacenter choice");
1464
+ rl.close();
1465
+ process.exit(1);
1466
+ }
1467
+
1468
+ const existingZoho = config.zohoAccounts.find((z) => z.email === email);
1469
+ if (existingZoho) {
1470
+ console.log(`\nZoho account ${email} already exists. Re-authenticating...`);
1471
+ existingZoho.datacenter = datacenter;
1472
+ saveConfig(config);
1473
+ } else {
1474
+ config.zohoAccounts.push({ email, datacenter });
1475
+ saveConfig(config);
1476
+ console.log(`\nAdded ${email} to Zoho accounts.`);
1477
+ }
1478
+
1479
+ await authenticateZohoAccount({ email, datacenter });
1480
+ console.log("\nDone!");
1481
+ } else {
1482
+ console.error("Error: Invalid choice. Please enter 1 or 2.");
1483
+ rl.close();
1484
+ process.exit(1);
1485
+ }
1486
+
1487
+ rl.close();
1488
+ return;
1489
+ }
1490
+
1491
+ // Handle --remove
1492
+ const removeIndex = args.indexOf("--remove");
1493
+ if (removeIndex !== -1) {
1494
+ const email = args[removeIndex + 1];
1495
+ if (!email || email.startsWith("--")) {
1496
+ console.error("Error: Please provide an email address after --remove");
1497
+ rl.close();
1498
+ process.exit(1);
1499
+ }
1500
+
1501
+ const config = loadConfig();
1502
+ const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
1503
+
1504
+ // Check Google accounts
1505
+ const googleIdx = gmailAccounts.indexOf(email);
1506
+ if (googleIdx !== -1) {
1507
+ if (config.gmailAccounts.length > 0) {
1508
+ config.gmailAccounts.splice(googleIdx, 1);
1509
+ } else {
1510
+ config.accounts.splice(googleIdx, 1);
1511
+ }
1512
+ saveConfig(config);
1513
+
1514
+ // Remove token files
1515
+ const tokenPath = getGoogleTokenPath(email);
1516
+ const legacyPath = getLegacyTokenPath(email);
1517
+ if (existsSync(tokenPath)) unlinkSync(tokenPath);
1518
+ if (existsSync(legacyPath)) unlinkSync(legacyPath);
1519
+
1520
+ console.log(`Removed Google account ${email}.`);
1521
+ rl.close();
1522
+ return;
1523
+ }
1524
+
1525
+ // Check Zoho accounts
1526
+ const zohoIdx = config.zohoAccounts.findIndex((z) => z.email === email);
1527
+ if (zohoIdx !== -1) {
1528
+ config.zohoAccounts.splice(zohoIdx, 1);
1529
+ saveConfig(config);
1530
+
1531
+ const tokenPath = getZohoTokenPath(email);
1532
+ if (existsSync(tokenPath)) unlinkSync(tokenPath);
1533
+
1534
+ console.log(`Removed Zoho account ${email}.`);
1535
+ rl.close();
1536
+ return;
1537
+ }
1538
+
1539
+ console.error(`Error: Account ${email} not found.`);
1540
+ rl.close();
1541
+ process.exit(1);
1542
+ }
1543
+
1544
+ // Default: authenticate all accounts
1545
+ const config = loadConfig();
1546
+ const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
1547
+ const hasAny = gmailAccounts.length > 0 || config.zohoAccounts.length > 0;
1548
+
1549
+ if (!hasAny) {
1550
+ console.log("No accounts configured.\n");
1551
+ console.log("Add an account using:");
1552
+ console.log(" glancebar auth --add your-email@gmail.com\n");
1553
+ rl.close();
1554
+ return;
1555
+ }
1556
+
1557
+ console.log("Glancebar - Calendar Authentication");
1558
+ console.log("====================================\n");
1559
+
1560
+ // Authenticate Google accounts
1561
+ for (const account of gmailAccounts) {
1562
+ let tokenPath = getGoogleTokenPath(account);
1563
+ if (!existsSync(tokenPath)) {
1564
+ tokenPath = getLegacyTokenPath(account);
1565
+ }
1566
+ if (existsSync(tokenPath)) {
1567
+ console.log(`Google: ${account} - Already authenticated`);
1568
+ const answer = await prompt(`Re-authenticate? (y/N): `);
1569
+ if (answer.toLowerCase() !== "y") continue;
1570
+ }
1571
+ await authenticateGoogleAccount(account);
1572
+ }
1573
+
1574
+ // Authenticate Zoho accounts
1575
+ for (const account of config.zohoAccounts) {
1576
+ const tokenPath = getZohoTokenPath(account.email);
1577
+ if (existsSync(tokenPath)) {
1578
+ console.log(`Zoho: ${account.email} [${account.datacenter}] - Already authenticated`);
1579
+ const answer = await prompt(`Re-authenticate? (y/N): `);
1580
+ if (answer.toLowerCase() !== "y") continue;
1581
+ }
1582
+ await authenticateZohoAccount(account);
1583
+ }
1584
+
1585
+ rl.close();
1586
+ console.log("\nAll accounts authenticated!");
1587
+ }
1588
+
1589
+ function handleConfig(args: string[]) {
1590
+ const config = loadConfig();
1591
+
1592
+ // Handle --reset
1593
+ if (args.includes("--reset")) {
1594
+ // Preserve all account types
1595
+ const { accounts, gmailAccounts, zohoAccounts } = config;
1596
+ saveConfig({ ...DEFAULT_CONFIG, accounts, gmailAccounts, zohoAccounts });
1597
+ console.log("Configuration reset to defaults (accounts preserved).");
1598
+ return;
1599
+ }
1600
+
1601
+ // Handle --lookahead
1602
+ const lookaheadIndex = args.indexOf("--lookahead");
1603
+ if (lookaheadIndex !== -1) {
1604
+ const value = parseInt(args[lookaheadIndex + 1], 10);
1605
+ if (isNaN(value) || value < 1 || value > 168) {
1606
+ console.error("Error: lookahead must be between 1 and 168 hours");
1607
+ process.exit(1);
1608
+ }
1609
+ config.lookaheadHours = value;
1610
+ saveConfig(config);
1611
+ console.log(`Lookahead hours set to ${value}`);
1612
+ return;
1613
+ }
1614
+
1615
+ // Handle --countdown-threshold
1616
+ const countdownIndex = args.indexOf("--countdown-threshold");
1617
+ if (countdownIndex !== -1) {
1618
+ const value = parseInt(args[countdownIndex + 1], 10);
1619
+ if (isNaN(value) || value < 0 || value > 1440) {
1620
+ console.error("Error: countdown-threshold must be between 0 and 1440 minutes");
1621
+ process.exit(1);
1622
+ }
1623
+ config.countdownThresholdMinutes = value;
1624
+ saveConfig(config);
1625
+ console.log(`Countdown threshold set to ${value} minutes`);
1626
+ return;
1627
+ }
1628
+
1629
+ // Handle --max-title
1630
+ const maxTitleIndex = args.indexOf("--max-title");
1631
+ if (maxTitleIndex !== -1) {
1632
+ const value = parseInt(args[maxTitleIndex + 1], 10);
1633
+ if (isNaN(value) || value < 10 || value > 500) {
1634
+ console.error("Error: max-title must be between 10 and 500");
1635
+ process.exit(1);
1636
+ }
1637
+ config.maxTitleLength = value;
1638
+ saveConfig(config);
1639
+ console.log(`Max title length set to ${value}`);
1640
+ return;
1641
+ }
1642
+
1643
+ // Handle --show-calendar
1644
+ const showCalIndex = args.indexOf("--show-calendar");
1645
+ if (showCalIndex !== -1) {
1646
+ const value = args[showCalIndex + 1]?.toLowerCase();
1647
+ if (value !== "true" && value !== "false") {
1648
+ console.error("Error: --show-calendar must be 'true' or 'false'");
1649
+ process.exit(1);
1650
+ }
1651
+ config.showCalendarName = value === "true";
1652
+ saveConfig(config);
1653
+ console.log(`Show calendar name set to ${value}`);
1654
+ return;
1655
+ }
1656
+
1657
+ // Handle --water-reminder
1658
+ const waterReminderIndex = args.indexOf("--water-reminder");
1659
+ if (waterReminderIndex !== -1) {
1660
+ const value = args[waterReminderIndex + 1]?.toLowerCase();
1661
+ if (value !== "true" && value !== "false") {
1662
+ console.error("Error: --water-reminder must be 'true' or 'false'");
1663
+ process.exit(1);
1664
+ }
1665
+ config.waterReminderEnabled = value === "true";
1666
+ saveConfig(config);
1667
+ console.log(`Water reminder ${value === "true" ? "enabled" : "disabled"}`);
1668
+ return;
1669
+ }
1670
+
1671
+ // Handle --stretch-reminder
1672
+ const stretchReminderIndex = args.indexOf("--stretch-reminder");
1673
+ if (stretchReminderIndex !== -1) {
1674
+ const value = args[stretchReminderIndex + 1]?.toLowerCase();
1675
+ if (value !== "true" && value !== "false") {
1676
+ console.error("Error: --stretch-reminder must be 'true' or 'false'");
1677
+ process.exit(1);
1678
+ }
1679
+ config.stretchReminderEnabled = value === "true";
1680
+ saveConfig(config);
1681
+ console.log(`Stretch reminder ${value === "true" ? "enabled" : "disabled"}`);
1682
+ return;
1683
+ }
1684
+
1685
+ // Handle --eye-reminder
1686
+ const eyeReminderIndex = args.indexOf("--eye-reminder");
1687
+ if (eyeReminderIndex !== -1) {
1688
+ const value = args[eyeReminderIndex + 1]?.toLowerCase();
1689
+ if (value !== "true" && value !== "false") {
1690
+ console.error("Error: --eye-reminder must be 'true' or 'false'");
1691
+ process.exit(1);
1692
+ }
1693
+ config.eyeReminderEnabled = value === "true";
1694
+ saveConfig(config);
1695
+ console.log(`Eye break reminder ${value === "true" ? "enabled" : "disabled"}`);
1696
+ return;
1697
+ }
1698
+
1699
+ // Handle --cpu-usage
1700
+ const cpuUsageIndex = args.indexOf("--cpu-usage");
1701
+ if (cpuUsageIndex !== -1) {
1702
+ const value = args[cpuUsageIndex + 1]?.toLowerCase();
1703
+ if (value !== "true" && value !== "false") {
1704
+ console.error("Error: --cpu-usage must be 'true' or 'false'");
1705
+ process.exit(1);
1706
+ }
1707
+ config.showCpuUsage = value === "true";
1708
+ saveConfig(config);
1709
+ console.log(`CPU usage display ${value === "true" ? "enabled" : "disabled"}`);
1710
+ return;
1711
+ }
1712
+
1713
+ // Handle --memory-usage
1714
+ const memoryUsageIndex = args.indexOf("--memory-usage");
1715
+ if (memoryUsageIndex !== -1) {
1716
+ const value = args[memoryUsageIndex + 1]?.toLowerCase();
1717
+ if (value !== "true" && value !== "false") {
1718
+ console.error("Error: --memory-usage must be 'true' or 'false'");
1719
+ process.exit(1);
1720
+ }
1721
+ config.showMemoryUsage = value === "true";
1722
+ saveConfig(config);
1723
+ console.log(`Memory usage display ${value === "true" ? "enabled" : "disabled"}`);
1724
+ return;
1725
+ }
1726
+
1727
+ // Handle --zoho-tasks
1728
+ const zohoTasksIndex = args.indexOf("--zoho-tasks");
1729
+ if (zohoTasksIndex !== -1) {
1730
+ const value = args[zohoTasksIndex + 1]?.toLowerCase();
1731
+ if (value !== "true" && value !== "false") {
1732
+ console.error("Error: --zoho-tasks must be 'true' or 'false'");
1733
+ process.exit(1);
1734
+ }
1735
+ config.showZohoTasks = value === "true";
1736
+ saveConfig(config);
1737
+ console.log(`Zoho tasks display ${value === "true" ? "enabled" : "disabled"}`);
1738
+ return;
1739
+ }
1740
+
1741
+ // Handle --max-tasks
1742
+ const maxTasksIndex = args.indexOf("--max-tasks");
1743
+ if (maxTasksIndex !== -1) {
1744
+ const value = parseInt(args[maxTasksIndex + 1], 10);
1745
+ if (isNaN(value) || value < 1 || value > 10) {
1746
+ console.error("Error: --max-tasks must be between 1 and 10");
1747
+ process.exit(1);
1748
+ }
1749
+ config.maxTasksToShow = value;
1750
+ saveConfig(config);
1751
+ console.log(`Max tasks to show set to ${value}`);
1752
+ return;
1753
+ }
1754
+
1755
+ // Handle --show-usage-limits
1756
+ const showUsageLimitsIndex = args.indexOf("--show-usage-limits");
1757
+ if (showUsageLimitsIndex !== -1) {
1758
+ const value = args[showUsageLimitsIndex + 1]?.toLowerCase();
1759
+ if (value !== "true" && value !== "false") {
1760
+ console.error("Error: --show-usage-limits must be 'true' or 'false'");
1761
+ process.exit(1);
1762
+ }
1763
+ config.showUsageLimits = value === "true";
1764
+ saveConfig(config);
1765
+ console.log(`Usage limits display ${value === "true" ? "enabled" : "disabled"}`);
1766
+ return;
1767
+ }
1768
+
1769
+ // Handle --show-5hour-limit
1770
+ const show5HourIndex = args.indexOf("--show-5hour-limit");
1771
+ if (show5HourIndex !== -1) {
1772
+ const value = args[show5HourIndex + 1]?.toLowerCase();
1773
+ if (value !== "true" && value !== "false") {
1774
+ console.error("Error: --show-5hour-limit must be 'true' or 'false'");
1775
+ process.exit(1);
1776
+ }
1777
+ config.show5HourLimit = value === "true";
1778
+ saveConfig(config);
1779
+ console.log(`5-hour limit display ${value === "true" ? "enabled" : "disabled"}`);
1780
+ return;
1781
+ }
1782
+
1783
+ // Handle --show-7day-limit
1784
+ const show7DayIndex = args.indexOf("--show-7day-limit");
1785
+ if (show7DayIndex !== -1) {
1786
+ const value = args[show7DayIndex + 1]?.toLowerCase();
1787
+ if (value !== "true" && value !== "false") {
1788
+ console.error("Error: --show-7day-limit must be 'true' or 'false'");
1789
+ process.exit(1);
1790
+ }
1791
+ config.show7DayLimit = value === "true";
1792
+ saveConfig(config);
1793
+ console.log(`7-day limit display ${value === "true" ? "enabled" : "disabled"}`);
1794
+ return;
1795
+ }
1796
+
1797
+ // Handle --show-7day-sonnet-limit
1798
+ const show7DaySonnetIndex = args.indexOf("--show-7day-sonnet-limit");
1799
+ if (show7DaySonnetIndex !== -1) {
1800
+ const value = args[show7DaySonnetIndex + 1]?.toLowerCase();
1801
+ if (value !== "true" && value !== "false") {
1802
+ console.error("Error: --show-7day-sonnet-limit must be 'true' or 'false'");
1803
+ process.exit(1);
1804
+ }
1805
+ config.show7DaySonnetLimit = value === "true";
1806
+ saveConfig(config);
1807
+ console.log(`7-day Sonnet limit display ${value === "true" ? "enabled" : "disabled"}`);
1808
+ return;
1809
+ }
1810
+
1811
+ // Handle --show-5hour-resets
1812
+ const show5HourResetsIndex = args.indexOf("--show-5hour-resets");
1813
+ if (show5HourResetsIndex !== -1) {
1814
+ const value = args[show5HourResetsIndex + 1]?.toLowerCase();
1815
+ if (value !== "true" && value !== "false") {
1816
+ console.error("Error: --show-5hour-resets must be 'true' or 'false'");
1817
+ process.exit(1);
1818
+ }
1819
+ config.show5HourResets = value === "true";
1820
+ saveConfig(config);
1821
+ console.log(`5-hour reset time display ${value === "true" ? "enabled" : "disabled"}`);
1822
+ return;
1823
+ }
1824
+
1825
+ // Handle --show-7day-resets
1826
+ const show7DayResetsIndex = args.indexOf("--show-7day-resets");
1827
+ if (show7DayResetsIndex !== -1) {
1828
+ const value = args[show7DayResetsIndex + 1]?.toLowerCase();
1829
+ if (value !== "true" && value !== "false") {
1830
+ console.error("Error: --show-7day-resets must be 'true' or 'false'");
1831
+ process.exit(1);
1832
+ }
1833
+ config.show7DayResets = value === "true";
1834
+ saveConfig(config);
1835
+ console.log(`7-day reset time display ${value === "true" ? "enabled" : "disabled"}`);
1836
+ return;
1837
+ }
1838
+
1839
+ // Handle --show-7day-sonnet-resets
1840
+ const show7DaySonnetResetsIndex = args.indexOf("--show-7day-sonnet-resets");
1841
+ if (show7DaySonnetResetsIndex !== -1) {
1842
+ const value = args[show7DaySonnetResetsIndex + 1]?.toLowerCase();
1843
+ if (value !== "true" && value !== "false") {
1844
+ console.error("Error: --show-7day-sonnet-resets must be 'true' or 'false'");
1845
+ process.exit(1);
1846
+ }
1847
+ config.show7DaySonnetResets = value === "true";
1848
+ saveConfig(config);
1849
+ console.log(`7-day Sonnet reset time display ${value === "true" ? "enabled" : "disabled"}`);
1850
+ return;
1851
+ }
1852
+
1853
+ // Handle --resets-time-format
1854
+ const resetsTimeFormatIndex = args.indexOf("--resets-time-format");
1855
+ if (resetsTimeFormatIndex !== -1) {
1856
+ const value = args[resetsTimeFormatIndex + 1]?.toLowerCase();
1857
+ if (value !== "relative" && value !== "absolute") {
1858
+ console.error("Error: --resets-time-format must be 'relative' or 'absolute'");
1859
+ process.exit(1);
1860
+ }
1861
+ config.resetsTimeFormat = value as "relative" | "absolute";
1862
+ saveConfig(config);
1863
+ console.log(`Resets time format set to ${value}`);
1864
+ return;
1865
+ }
1866
+
1867
+ // Handle --usage-cache-ttl
1868
+ const cacheTTLIndex = args.indexOf("--usage-cache-ttl");
1869
+ if (cacheTTLIndex !== -1) {
1870
+ const value = parseInt(args[cacheTTLIndex + 1], 10);
1871
+ if (isNaN(value) || value < 60 || value > 3600) {
1872
+ console.error("Error: --usage-cache-ttl must be between 60 and 3600 seconds");
1873
+ process.exit(1);
1874
+ }
1875
+ config.usageLimitsCacheTTL = value;
1876
+ saveConfig(config);
1877
+ console.log(`API cache TTL set to ${value} seconds`);
1878
+ return;
1879
+ }
1880
+
1881
+ // Show current config
1882
+ const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
1883
+ const googleStr = gmailAccounts.length > 0 ? gmailAccounts.join(", ") : "(none)";
1884
+ const zohoStr = config.zohoAccounts.length > 0
1885
+ ? config.zohoAccounts.map((z) => `${z.email} [${z.datacenter}]`).join(", ")
1886
+ : "(none)";
1887
+
1888
+ console.log(`
1889
+ Glancebar Configuration
1890
+ =======================
1891
+ Config directory: ${getConfigDir()}
1892
+
1893
+ Accounts:
1894
+ Google Calendar: ${googleStr}
1895
+ Zoho Calendar: ${zohoStr}
1896
+
1897
+ Calendar Settings:
1898
+ Lookahead hours: ${config.lookaheadHours}
1899
+ Countdown threshold: ${config.countdownThresholdMinutes} minutes
1900
+ Max title length: ${config.maxTitleLength}
1901
+ Show calendar name: ${config.showCalendarName}
1902
+
1903
+ Reminders:
1904
+ Water reminder: ${config.waterReminderEnabled ? "enabled" : "disabled"}
1905
+ Stretch reminder: ${config.stretchReminderEnabled ? "enabled" : "disabled"}
1906
+ Eye break reminder: ${config.eyeReminderEnabled ? "enabled" : "disabled"}
1907
+
1908
+ System Stats:
1909
+ CPU usage: ${config.showCpuUsage ? "enabled" : "disabled"}
1910
+ Memory usage: ${config.showMemoryUsage ? "enabled" : "disabled"}
1911
+
1912
+ Zoho Tasks:
1913
+ Show tasks: ${config.showZohoTasks ? "enabled" : "disabled"}
1914
+ Max tasks to show: ${config.maxTasksToShow}
1915
+
1916
+ Usage Limits:
1917
+ Show usage limits: ${config.showUsageLimits ? "enabled" : "disabled"}
1918
+ Show 5-hour limit: ${config.show5HourLimit ? "enabled" : "disabled"}
1919
+ Show 7-day limit: ${config.show7DayLimit ? "enabled" : "disabled"}
1920
+ Show 7-day Sonnet limit: ${config.show7DaySonnetLimit ? "enabled" : "disabled"}
1921
+ Show 5-hour resets: ${config.show5HourResets ? "enabled" : "disabled"}
1922
+ Show 7-day resets: ${config.show7DayResets ? "enabled" : "disabled"}
1923
+ Show 7-day Sonnet resets: ${config.show7DaySonnetResets ? "enabled" : "disabled"}
1924
+ Resets time format: ${config.resetsTimeFormat}
1925
+ Cache TTL: ${config.usageLimitsCacheTTL} seconds
1926
+ `);
1927
+ }
1928
+
1929
+ function getRandomReminder(config: Config): string | null {
1930
+ const enabledReminders: Array<() => string> = [];
1931
+
1932
+ if (config.waterReminderEnabled) {
1933
+ enabledReminders.push(() => {
1934
+ const reminder = WATER_REMINDERS[Math.floor(Math.random() * WATER_REMINDERS.length)];
1935
+ return `${COLORS.brightCyan}${reminder}${COLORS.reset}`;
1936
+ });
1937
+ }
1938
+
1939
+ if (config.stretchReminderEnabled) {
1940
+ enabledReminders.push(() => {
1941
+ const reminder = STRETCH_REMINDERS[Math.floor(Math.random() * STRETCH_REMINDERS.length)];
1942
+ return `${COLORS.brightGreen}${reminder}${COLORS.reset}`;
1943
+ });
1944
+ }
1945
+
1946
+ if (config.eyeReminderEnabled) {
1947
+ enabledReminders.push(() => {
1948
+ const reminder = EYE_REMINDERS[Math.floor(Math.random() * EYE_REMINDERS.length)];
1949
+ return `${COLORS.brightMagenta}${reminder}${COLORS.reset}`;
1950
+ });
1951
+ }
1952
+
1953
+ if (enabledReminders.length === 0) return null;
1954
+
1955
+ // ~5% chance to show any reminder (reduced from 30% to be less intrusive)
1956
+ if (Math.random() >= 0.05) return null;
1957
+
1958
+ // Pick a random reminder type from enabled ones
1959
+ const randomPicker = enabledReminders[Math.floor(Math.random() * enabledReminders.length)];
1960
+ return randomPicker();
1961
+ }
1962
+
1963
+ interface ClaudeCodeStatus {
1964
+ model?: { display_name?: string };
1965
+ cost?: {
1966
+ total_cost_usd?: number;
1967
+ total_lines_added?: number;
1968
+ total_lines_removed?: number;
1969
+ };
1970
+ cwd?: string;
1971
+ workspace?: {
1972
+ project_dir?: string;
1973
+ };
1974
+ context_window?: {
1975
+ context_window_size?: number;
1976
+ current_usage?: {
1977
+ input_tokens?: number;
1978
+ output_tokens?: number;
1979
+ cache_creation_input_tokens?: number;
1980
+ cache_read_input_tokens?: number;
1981
+ };
1982
+ };
1983
+ }
1984
+
1985
+ function getGitBranch(cwd?: string): string | null {
1986
+ try {
1987
+ const { execSync } = require("child_process");
1988
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
1989
+ cwd: cwd || process.cwd(),
1990
+ encoding: "utf-8",
1991
+ stdio: ["pipe", "pipe", "pipe"],
1992
+ }).trim();
1993
+
1994
+ // Check if there are uncommitted changes
1995
+ let isDirty = false;
1996
+ try {
1997
+ const status = execSync("git status --porcelain", {
1998
+ cwd: cwd || process.cwd(),
1999
+ encoding: "utf-8",
2000
+ stdio: ["pipe", "pipe", "pipe"],
2001
+ }).trim();
2002
+ isDirty = status.length > 0;
2003
+ } catch {}
2004
+
2005
+ return isDirty ? `${branch}*` : branch;
2006
+ } catch {
2007
+ return null;
2008
+ }
2009
+ }
2010
+
2011
+ function getProjectName(projectDir?: string): string | null {
2012
+ if (!projectDir) return null;
2013
+ // Extract the last part of the path as project name
2014
+ const parts = projectDir.replace(/\\/g, "/").split("/").filter(Boolean);
2015
+ return parts.length > 0 ? parts[parts.length - 1] : null;
2016
+ }
2017
+
2018
+ async function readStdinJson(): Promise<ClaudeCodeStatus | null> {
2019
+ try {
2020
+ const chunks: Uint8Array[] = [];
2021
+ for await (const chunk of Bun.stdin.stream()) {
2022
+ chunks.push(chunk);
2023
+ }
2024
+ if (chunks.length === 0) return null;
2025
+ const text = Buffer.concat(chunks).toString("utf-8").trim();
2026
+ if (!text) return null;
2027
+ return JSON.parse(text);
2028
+ } catch {
2029
+ return null;
2030
+ }
2031
+ }
2032
+
2033
+ function formatSessionInfo(status: ClaudeCodeStatus): string {
2034
+ const parts: string[] = [];
2035
+
2036
+ // Project name
2037
+ const projectName = getProjectName(status.workspace?.project_dir);
2038
+ if (projectName) {
2039
+ parts.push(`${COLORS.brightBlue}${projectName}${COLORS.reset}`);
2040
+ }
2041
+
2042
+ // Git branch
2043
+ const gitBranch = getGitBranch(status.cwd || status.workspace?.project_dir);
2044
+ if (gitBranch) {
2045
+ parts.push(`${COLORS.magenta}${gitBranch}${COLORS.reset}`);
2046
+ }
2047
+
2048
+ // Model name
2049
+ if (status.model?.display_name) {
2050
+ parts.push(`${COLORS.brightYellow}${status.model.display_name}${COLORS.reset}`);
2051
+ }
2052
+
2053
+ // Cost
2054
+ if (status.cost?.total_cost_usd !== undefined) {
2055
+ const cost = status.cost.total_cost_usd;
2056
+ const costStr = cost < 0.01 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
2057
+ parts.push(`${COLORS.green}${costStr}${COLORS.reset}`);
2058
+ }
2059
+
2060
+ // Lines changed
2061
+ const linesAdded = status.cost?.total_lines_added || 0;
2062
+ const linesRemoved = status.cost?.total_lines_removed || 0;
2063
+ if (linesAdded > 0 || linesRemoved > 0) {
2064
+ const linesStr = `${COLORS.green}+${linesAdded}${COLORS.reset} ${COLORS.red}-${linesRemoved}${COLORS.reset}`;
2065
+ parts.push(linesStr);
2066
+ }
2067
+
2068
+ // Context usage (using current_usage for accurate context window state)
2069
+ if (status.context_window?.current_usage && status.context_window?.context_window_size) {
2070
+ const usage = status.context_window.current_usage;
2071
+ // Sum all token types for total context usage
2072
+ const totalUsed =
2073
+ (usage.input_tokens || 0) +
2074
+ (usage.output_tokens || 0) +
2075
+ (usage.cache_creation_input_tokens || 0) +
2076
+ (usage.cache_read_input_tokens || 0);
2077
+ const windowSize = status.context_window.context_window_size;
2078
+ const percentage = Math.round((totalUsed / windowSize) * 100);
2079
+ const usedK = (totalUsed / 1000).toFixed(1);
2080
+ const windowK = Math.round(windowSize / 1000);
2081
+
2082
+ // Color based on usage: green < 50%, yellow 50-80%, red > 80%
2083
+ let color = COLORS.green;
2084
+ if (percentage >= 80) color = COLORS.red;
2085
+ else if (percentage >= 50) color = COLORS.yellow;
2086
+
2087
+ parts.push(`${color}${usedK}k/${windowK}k (${percentage}%)${COLORS.reset}`);
2088
+ }
2089
+
2090
+ return parts.join(" | ");
2091
+ }
2092
+
2093
+ async function outputStatusline() {
2094
+ try {
2095
+ const config = loadConfig();
2096
+
2097
+ // Read and parse stdin from Claude Code
2098
+ const status = await readStdinJson();
2099
+ const parts: string[] = [];
2100
+
2101
+ // Add session info from Claude Code
2102
+ if (status) {
2103
+ const sessionInfo = formatSessionInfo(status);
2104
+ if (sessionInfo) {
2105
+ parts.push(sessionInfo);
2106
+ }
2107
+ }
2108
+
2109
+ // Fetch and display usage limits
2110
+ if (config.showUsageLimits) {
2111
+ const usageLimits = await getUsageLimits(config);
2112
+ if (usageLimits) {
2113
+ const limitsStr = formatUsageLimits(usageLimits, config);
2114
+ if (limitsStr) {
2115
+ parts.push(limitsStr);
2116
+ }
2117
+ }
2118
+ }
2119
+
2120
+ // Add system stats if enabled
2121
+ if (config.showCpuUsage) {
2122
+ const cpu = getCpuUsage();
2123
+ if (cpu) parts.push(cpu);
2124
+ }
2125
+ if (config.showMemoryUsage) {
2126
+ const mem = getMemoryUsage();
2127
+ if (mem) parts.push(mem);
2128
+ }
2129
+
2130
+ // Check for health reminder (water, stretch, eye break)
2131
+ const reminder = getRandomReminder(config);
2132
+ if (reminder) {
2133
+ parts.push(reminder);
2134
+ }
2135
+
2136
+ // Get calendar events and tasks in parallel
2137
+ const gmailAccounts = config.gmailAccounts.length > 0 ? config.gmailAccounts : config.accounts;
2138
+ const hasAccounts = gmailAccounts.length > 0 || config.zohoAccounts.length > 0;
2139
+
2140
+ if (hasAccounts) {
2141
+ const [events, tasks] = await Promise.all([
2142
+ getUpcomingEvents(config),
2143
+ getZohoTasks(config),
2144
+ ]);
2145
+
2146
+ const event = getCurrentOrNextEvent(events);
2147
+
2148
+ // Check for meeting warning (within 5 minutes)
2149
+ const meetingWarning = getMeetingWarning(event);
2150
+ if (meetingWarning) {
2151
+ parts.push(meetingWarning);
2152
+ }
2153
+
2154
+ // Add tasks if available
2155
+ const tasksStr = formatTasks(tasks);
2156
+ if (tasksStr) {
2157
+ parts.push(tasksStr);
2158
+ }
2159
+
2160
+ if (event) {
2161
+ parts.push(formatEvent(event, config));
2162
+ } else if (parts.length === 0) {
2163
+ parts.push("No upcoming events");
2164
+ }
2165
+ } else if (parts.length === 0) {
2166
+ parts.push("No accounts configured");
2167
+ }
2168
+
2169
+ console.log(parts.join(" | "));
2170
+ } catch {
2171
+ console.log("Calendar unavailable");
2172
+ }
2173
+ }
2174
+
2175
+ // ============================================================================
2176
+ // Main
2177
+ // ============================================================================
2178
+
2179
+ async function main() {
2180
+ const args = process.argv.slice(2);
2181
+ const command = args[0];
2182
+
2183
+ if (!command) {
2184
+ // Default: output statusline
2185
+ await outputStatusline();
2186
+ return;
2187
+ }
2188
+
2189
+ switch (command) {
2190
+ case "version":
2191
+ case "--version":
2192
+ case "-v":
2193
+ console.log(`@naarang/glancebar v${VERSION}`);
2194
+ break;
2195
+
2196
+ case "help":
2197
+ case "--help":
2198
+ case "-h":
2199
+ printHelp();
2200
+ break;
2201
+
2202
+ case "setup":
2203
+ printSetup();
2204
+ break;
2205
+
2206
+ case "auth":
2207
+ await handleAuth(args.slice(1));
2208
+ break;
2209
+
2210
+ case "config":
2211
+ handleConfig(args.slice(1));
2212
+ break;
2213
+
2214
+ default:
2215
+ console.error(`Unknown command: ${command}`);
2216
+ console.error("Run 'glancebar --help' for usage.");
2217
+ process.exit(1);
2218
+ }
2219
+ }
2220
+
2221
+ main().catch((err) => {
2222
+ console.error("Error:", err.message);
2223
+ process.exit(1);
2224
+ });