@naarang/glancebar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +230 -0
  3. package/package.json +63 -0
  4. package/src/cli.ts +813 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Vishal Dubey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # @naarang/glancebar
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@naarang/glancebar.svg)](https://www.npmjs.com/package/@naarang/glancebar)
4
+ [![license](https://img.shields.io/npm/l/@naarang/glancebar.svg)](https://github.com/vishal-android-freak/glancebar/blob/main/LICENSE)
5
+
6
+ A customizable statusline for [Claude Code](https://claude.ai/claude-code) - display calendar events, tasks, and more at a glance.
7
+
8
+ ## Features
9
+
10
+ - Display upcoming calendar events from multiple Google accounts
11
+ - Color-coded events per account
12
+ - Countdown display for imminent events
13
+ - Water break reminders to stay hydrated
14
+ - Fully configurable via CLI
15
+ - Cross-platform support (Windows, macOS, Linux)
16
+
17
+ ## Requirements
18
+
19
+ - [Bun](https://bun.sh/) >= 1.0.0
20
+ - Google Cloud project with Calendar API enabled
21
+
22
+ ## Installation
23
+
24
+ ### Using bunx (recommended)
25
+
26
+ ```bash
27
+ bunx @naarang/glancebar --help
28
+ ```
29
+
30
+ ### Global installation
31
+
32
+ ```bash
33
+ bun install -g @naarang/glancebar
34
+ ```
35
+
36
+ ### Using npm
37
+
38
+ ```bash
39
+ npx @naarang/glancebar --help
40
+ # or
41
+ npm install -g @naarang/glancebar
42
+ ```
43
+
44
+ ## Quick Start
45
+
46
+ ```bash
47
+ # 1. Run setup guide
48
+ glancebar setup
49
+
50
+ # 2. Add your Google account
51
+ glancebar auth --add your-email@gmail.com
52
+
53
+ # 3. Test it
54
+ glancebar
55
+ ```
56
+
57
+ ## Setup
58
+
59
+ ### 1. Create Google Cloud Project
60
+
61
+ 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
62
+ 2. Create a new project or select an existing one
63
+ 3. Enable the **Google Calendar API**:
64
+ - Go to "APIs & Services" > "Library"
65
+ - Search for "Google Calendar API" and enable it
66
+
67
+ ### 2. Create OAuth Credentials
68
+
69
+ 1. Go to "APIs & Services" > "Credentials"
70
+ 2. Click "Create Credentials" > "OAuth client ID"
71
+ 3. Select "Desktop app" as application type
72
+ 4. Download the JSON file
73
+ 5. Rename to `credentials.json` and save to `~/.glancebar/credentials.json`
74
+
75
+ ### 3. Add Redirect URI
76
+
77
+ In Google Cloud Console, edit your OAuth client and add:
78
+
79
+ ```
80
+ http://localhost:3000/callback
81
+ ```
82
+
83
+ ### 4. Add Accounts
84
+
85
+ ```bash
86
+ glancebar auth --add your-email@gmail.com
87
+ glancebar auth --add work@company.com
88
+ ```
89
+
90
+ ### 5. Configure Claude Code
91
+
92
+ Update `~/.claude/settings.json`:
93
+
94
+ ```json
95
+ {
96
+ "statusLine": {
97
+ "type": "command",
98
+ "command": "bunx @naarang/glancebar",
99
+ "padding": 0
100
+ }
101
+ }
102
+ ```
103
+
104
+ ## Usage
105
+
106
+ ### Statusline Output
107
+
108
+ ```bash
109
+ glancebar
110
+ # Output: In 15m: Team Standup (work)
111
+ ```
112
+
113
+ ### Commands
114
+
115
+ | Command | Description |
116
+ |---------|-------------|
117
+ | `glancebar` | Display statusline output |
118
+ | `glancebar auth` | Re-authenticate all accounts |
119
+ | `glancebar auth --add <email>` | Add a new account |
120
+ | `glancebar auth --remove <email>` | Remove an account |
121
+ | `glancebar auth --list` | List all accounts |
122
+ | `glancebar config` | Show current configuration |
123
+ | `glancebar setup` | Show setup instructions |
124
+ | `glancebar --help` | Show help |
125
+
126
+ ### Configuration Options
127
+
128
+ ```bash
129
+ # Set lookahead hours (how far ahead to look for events)
130
+ glancebar config --lookahead 12
131
+
132
+ # Set countdown threshold (show "In Xm" instead of time)
133
+ glancebar config --countdown-threshold 30
134
+
135
+ # Set max title length
136
+ glancebar config --max-title 80
137
+
138
+ # Toggle calendar name display
139
+ glancebar config --show-calendar false
140
+
141
+ # Enable/disable water reminders
142
+ glancebar config --water-reminder true
143
+
144
+ # Set water reminder interval (in minutes)
145
+ glancebar config --water-interval 45
146
+
147
+ # Reset to defaults
148
+ glancebar config --reset
149
+ ```
150
+
151
+ ## Display Format
152
+
153
+ | State | Format | Example |
154
+ |-------|--------|---------|
155
+ | Upcoming (within threshold) | `In Xm: Title (account)` | `In 15m: Team Standup (work)` |
156
+ | Current | `Now: Title (account)` | `Now: Team Standup (work)` |
157
+ | Later | `HH:MM AM/PM: Title (account)` | `2:30 PM: Meeting (work)` |
158
+ | No events | `No upcoming events` | |
159
+ | Water reminder | Random hydration message | `Stay hydrated! Drink some water` |
160
+
161
+ Events are color-coded by account (cyan, magenta, green, orange, blue, pink, yellow, purple).
162
+
163
+ ## Configuration
164
+
165
+ All configuration is stored in `~/.glancebar/`:
166
+
167
+ ```
168
+ ~/.glancebar/
169
+ ├── config.json # User settings
170
+ ├── credentials.json # Google OAuth credentials (you provide this)
171
+ └── tokens/ # OAuth tokens per account
172
+ └── <email>.json
173
+ ```
174
+
175
+ ### Default Settings
176
+
177
+ | Setting | Default | Description |
178
+ |---------|---------|-------------|
179
+ | `lookaheadHours` | 8 | Hours ahead to look for events |
180
+ | `countdownThresholdMinutes` | 60 | Minutes threshold for countdown display |
181
+ | `maxTitleLength` | 120 | Maximum event title length |
182
+ | `showCalendarName` | true | Show account name after event |
183
+ | `waterReminderEnabled` | false | Enable water break reminders |
184
+ | `waterReminderIntervalMinutes` | 45 | Minutes between water reminders |
185
+
186
+ ## Building from Source
187
+
188
+ ```bash
189
+ # Clone the repository
190
+ git clone https://github.com/vishal-android-freak/glancebar.git
191
+ cd glancebar
192
+
193
+ # Install dependencies
194
+ bun install
195
+
196
+ # Run locally
197
+ bun run dev
198
+
199
+ # Build binaries for all platforms
200
+ bun run build:all
201
+ ```
202
+
203
+ ### Build Targets
204
+
205
+ | Platform | Command |
206
+ |----------|---------|
207
+ | Linux x64 | `bun run build:linux-x64` |
208
+ | Linux ARM64 | `bun run build:linux-arm64` |
209
+ | macOS x64 | `bun run build:darwin-x64` |
210
+ | macOS ARM64 | `bun run build:darwin-arm64` |
211
+ | Windows x64 | `bun run build:win-x64` |
212
+
213
+ ## Roadmap
214
+
215
+ - [ ] Task integration (Todoist, Google Tasks)
216
+ - [ ] Weather information
217
+ - [ ] System stats
218
+ - [ ] Custom modules
219
+
220
+ ## Contributing
221
+
222
+ Contributions are welcome! Please feel free to submit a Pull Request.
223
+
224
+ ## Author
225
+
226
+ **Vishal Dubey** ([@vishal-android-freak](https://github.com/vishal-android-freak))
227
+
228
+ ## License
229
+
230
+ [MIT](LICENSE)
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@naarang/glancebar",
3
+ "version": "1.0.0",
4
+ "description": "A customizable statusline for Claude Code - display calendar events, tasks, and more at a glance",
5
+ "author": "Vishal Dubey",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "bin": {
12
+ "glancebar": "src/cli.ts"
13
+ },
14
+ "files": [
15
+ "src/cli.ts",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "dev": "bun run src/cli.ts",
21
+ "build:all": "bun run build:linux-x64 && bun run build:linux-arm64 && bun run build:darwin-x64 && bun run build:darwin-arm64 && bun run build:win-x64",
22
+ "build:linux-x64": "bun build src/cli.ts --compile --target=bun-linux-x64 --outfile dist/glancebar-linux-x64",
23
+ "build:linux-arm64": "bun build src/cli.ts --compile --target=bun-linux-arm64 --outfile dist/glancebar-linux-arm64",
24
+ "build:darwin-x64": "bun build src/cli.ts --compile --target=bun-darwin-x64 --outfile dist/glancebar-darwin-x64",
25
+ "build:darwin-arm64": "bun build src/cli.ts --compile --target=bun-darwin-arm64 --outfile dist/glancebar-darwin-arm64",
26
+ "build:win-x64": "bun build src/cli.ts --compile --target=bun-windows-x64 --outfile dist/glancebar-win-x64.exe"
27
+ },
28
+ "keywords": [
29
+ "claude-code",
30
+ "statusline",
31
+ "glancebar",
32
+ "google-calendar",
33
+ "calendar",
34
+ "productivity",
35
+ "cli",
36
+ "bun"
37
+ ],
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/vishal-android-freak/glancebar.git"
41
+ },
42
+ "os": [
43
+ "darwin",
44
+ "linux",
45
+ "win32"
46
+ ],
47
+ "cpu": [
48
+ "x64",
49
+ "arm64"
50
+ ],
51
+ "engines": {
52
+ "bun": ">=1.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/bun": "latest"
56
+ },
57
+ "peerDependencies": {
58
+ "typescript": "^5"
59
+ },
60
+ "dependencies": {
61
+ "googleapis": "^170.0.0"
62
+ }
63
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,813 @@
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
+
8
+ // ============================================================================
9
+ // Configuration
10
+ // ============================================================================
11
+
12
+ interface Config {
13
+ accounts: string[];
14
+ lookaheadHours: number;
15
+ showCalendarName: boolean;
16
+ countdownThresholdMinutes: number;
17
+ maxTitleLength: number;
18
+ waterReminderEnabled: boolean;
19
+ waterReminderIntervalMinutes: number;
20
+ }
21
+
22
+ const COLORS: Record<string, string> = {
23
+ reset: "\x1b[0m",
24
+ red: "\x1b[31m",
25
+ green: "\x1b[32m",
26
+ yellow: "\x1b[33m",
27
+ blue: "\x1b[34m",
28
+ magenta: "\x1b[35m",
29
+ cyan: "\x1b[36m",
30
+ white: "\x1b[37m",
31
+ brightRed: "\x1b[91m",
32
+ brightGreen: "\x1b[92m",
33
+ brightYellow: "\x1b[93m",
34
+ brightBlue: "\x1b[94m",
35
+ brightMagenta: "\x1b[95m",
36
+ brightCyan: "\x1b[96m",
37
+ orange: "\x1b[38;5;208m",
38
+ pink: "\x1b[38;5;213m",
39
+ purple: "\x1b[38;5;141m",
40
+ };
41
+
42
+ const ACCOUNT_COLORS = ["cyan", "magenta", "brightGreen", "orange", "brightBlue", "pink", "yellow", "purple"];
43
+
44
+ const DEFAULT_CONFIG: Config = {
45
+ accounts: [],
46
+ lookaheadHours: 8,
47
+ showCalendarName: true,
48
+ countdownThresholdMinutes: 60,
49
+ maxTitleLength: 120,
50
+ waterReminderEnabled: true,
51
+ waterReminderIntervalMinutes: 30,
52
+ };
53
+
54
+ const WATER_REMINDERS = [
55
+ "Stay hydrated! Drink some water",
56
+ "Time for a water break!",
57
+ "Hydration check! Grab some water",
58
+ "Your body needs water. Drink up!",
59
+ "Water break! Stay refreshed",
60
+ "Don't forget to drink water!",
61
+ "Hydrate yourself! Take a sip",
62
+ "Quick reminder: Drink water!",
63
+ ];
64
+
65
+ function getConfigDir(): string {
66
+ const home = process.env.HOME || process.env.USERPROFILE || "";
67
+ return join(home, ".glancebar");
68
+ }
69
+
70
+ function getConfigPath(): string {
71
+ return join(getConfigDir(), "config.json");
72
+ }
73
+
74
+ function getTokensDir(): string {
75
+ return join(getConfigDir(), "tokens");
76
+ }
77
+
78
+ function getCredentialsPath(): string {
79
+ return join(getConfigDir(), "credentials.json");
80
+ }
81
+
82
+ function ensureConfigDir(): void {
83
+ const dir = getConfigDir();
84
+ if (!existsSync(dir)) {
85
+ mkdirSync(dir, { recursive: true });
86
+ }
87
+ }
88
+
89
+ function loadConfig(): Config {
90
+ const configPath = getConfigPath();
91
+ if (!existsSync(configPath)) {
92
+ return { ...DEFAULT_CONFIG };
93
+ }
94
+
95
+ try {
96
+ const content = readFileSync(configPath, "utf-8");
97
+ const userConfig = JSON.parse(content);
98
+ return { ...DEFAULT_CONFIG, ...userConfig };
99
+ } catch {
100
+ return { ...DEFAULT_CONFIG };
101
+ }
102
+ }
103
+
104
+ function saveConfig(config: Config): void {
105
+ ensureConfigDir();
106
+ writeFileSync(getConfigPath(), JSON.stringify(config, null, 2));
107
+ }
108
+
109
+ // ============================================================================
110
+ // OAuth Authentication
111
+ // ============================================================================
112
+
113
+ const SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"];
114
+ const REDIRECT_URI = "http://localhost:3000/callback";
115
+
116
+ interface Credentials {
117
+ installed?: { client_id: string; client_secret: string };
118
+ web?: { client_id: string; client_secret: string };
119
+ }
120
+
121
+ function loadCredentials(): Credentials {
122
+ const credPath = getCredentialsPath();
123
+ if (!existsSync(credPath)) {
124
+ throw new Error(
125
+ `credentials.json not found at ${credPath}\n\nPlease download OAuth credentials from Google Cloud Console and save to:\n${credPath}`
126
+ );
127
+ }
128
+ return JSON.parse(readFileSync(credPath, "utf-8"));
129
+ }
130
+
131
+ function getTokenPath(account: string): string {
132
+ const safeAccount = account.replace(/[^a-zA-Z0-9@.-]/g, "_");
133
+ return join(getTokensDir(), `${safeAccount}.json`);
134
+ }
135
+
136
+ function createOAuth2Client(credentials: Credentials) {
137
+ const { client_id, client_secret } = credentials.installed || credentials.web!;
138
+ return new google.auth.OAuth2(client_id, client_secret, REDIRECT_URI);
139
+ }
140
+
141
+ function getAuthenticatedClient(account: string) {
142
+ const credentials = loadCredentials();
143
+ const oauth2Client = createOAuth2Client(credentials);
144
+ const tokenPath = getTokenPath(account);
145
+
146
+ if (!existsSync(tokenPath)) {
147
+ return null;
148
+ }
149
+
150
+ const token = JSON.parse(readFileSync(tokenPath, "utf-8"));
151
+ oauth2Client.setCredentials(token);
152
+
153
+ oauth2Client.on("tokens", (tokens) => {
154
+ const currentToken = JSON.parse(readFileSync(tokenPath, "utf-8"));
155
+ const updatedToken = { ...currentToken, ...tokens };
156
+ writeFileSync(tokenPath, JSON.stringify(updatedToken, null, 2));
157
+ });
158
+
159
+ return oauth2Client;
160
+ }
161
+
162
+ async function authenticateAccount(account: string): Promise<void> {
163
+ const credentials = loadCredentials();
164
+ const oauth2Client = createOAuth2Client(credentials);
165
+
166
+ const authUrl = oauth2Client.generateAuthUrl({
167
+ access_type: "offline",
168
+ scope: SCOPES,
169
+ prompt: "consent",
170
+ login_hint: account,
171
+ });
172
+
173
+ console.log(`\nAuthenticating: ${account}`);
174
+ console.log(`Opening browser...`);
175
+
176
+ const code = await startServerAndGetCode(authUrl);
177
+
178
+ console.log(`Exchanging code for tokens...`);
179
+
180
+ const { tokens } = await oauth2Client.getToken(code);
181
+ oauth2Client.setCredentials(tokens);
182
+
183
+ const tokensDir = getTokensDir();
184
+ if (!existsSync(tokensDir)) {
185
+ mkdirSync(tokensDir, { recursive: true });
186
+ }
187
+
188
+ const tokenPath = getTokenPath(account);
189
+ writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
190
+ console.log(`Token saved for ${account}`);
191
+ }
192
+
193
+ function startServerAndGetCode(authUrl: string): Promise<string> {
194
+ return new Promise((resolve, reject) => {
195
+ let server: Server;
196
+
197
+ server = createServer(async (req, res) => {
198
+ const url = new URL(req.url!, `http://localhost:3000`);
199
+
200
+ if (!url.pathname.startsWith("/callback")) {
201
+ res.writeHead(404);
202
+ res.end("Not found");
203
+ return;
204
+ }
205
+
206
+ const code = url.searchParams.get("code");
207
+ const error = url.searchParams.get("error");
208
+
209
+ if (error) {
210
+ res.writeHead(400, { "Content-Type": "text/html" });
211
+ res.end(`<html><body><h1>Authentication failed</h1><p>Error: ${error}</p></body></html>`);
212
+ server.close();
213
+ reject(new Error(error));
214
+ return;
215
+ }
216
+
217
+ if (code) {
218
+ res.writeHead(200, { "Content-Type": "text/html" });
219
+ res.end(`
220
+ <html>
221
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee;">
222
+ <div style="text-align: center;">
223
+ <h1 style="color: #4ade80;">Authentication Successful!</h1>
224
+ <p>You can close this window and return to the terminal.</p>
225
+ </div>
226
+ </body>
227
+ </html>
228
+ `);
229
+
230
+ setTimeout(() => {
231
+ server.close(() => resolve(code));
232
+ }, 500);
233
+ } else {
234
+ res.writeHead(400, { "Content-Type": "text/html" });
235
+ res.end("<html><body><h1>Authentication failed</h1><p>No code received.</p></body></html>");
236
+ }
237
+ });
238
+
239
+ server.listen(3000, () => openBrowser(authUrl));
240
+
241
+ server.on("error", (err: NodeJS.ErrnoException) => {
242
+ if (err.code === "EADDRINUSE") {
243
+ reject(new Error("Port 3000 is already in use. Please close any application using it and try again."));
244
+ } else {
245
+ reject(err);
246
+ }
247
+ });
248
+
249
+ setTimeout(() => {
250
+ server.close();
251
+ reject(new Error("Authentication timeout (5 minutes)"));
252
+ }, 300000);
253
+ });
254
+ }
255
+
256
+ function openBrowser(url: string) {
257
+ const { exec } = require("child_process");
258
+ const platform = process.platform;
259
+
260
+ let command: string;
261
+ if (platform === "win32") {
262
+ command = `start "" "${url}"`;
263
+ } else if (platform === "darwin") {
264
+ command = `open "${url}"`;
265
+ } else {
266
+ command = `xdg-open "${url}"`;
267
+ }
268
+
269
+ exec(command, (err: Error | null) => {
270
+ if (err) {
271
+ console.log(`\nCould not open browser automatically.`);
272
+ console.log(`Please open this URL manually:\n${url}\n`);
273
+ }
274
+ });
275
+ }
276
+
277
+ // ============================================================================
278
+ // Calendar
279
+ // ============================================================================
280
+
281
+ interface CalendarEvent {
282
+ id: string;
283
+ title: string;
284
+ start: Date;
285
+ end: Date;
286
+ isAllDay: boolean;
287
+ account: string;
288
+ accountEmail: string;
289
+ accountIndex: number;
290
+ }
291
+
292
+ async function getUpcomingEvents(config: Config): Promise<CalendarEvent[]> {
293
+ const allEvents: CalendarEvent[] = [];
294
+ const now = new Date();
295
+ const timeMax = new Date(now.getTime() + config.lookaheadHours * 60 * 60 * 1000);
296
+
297
+ const eventPromises = config.accounts.map(async (account, accountIndex) => {
298
+ try {
299
+ const auth = getAuthenticatedClient(account);
300
+ if (!auth) return [];
301
+
302
+ const calendar = google.calendar({ version: "v3", auth });
303
+
304
+ const response = await calendar.events.list({
305
+ calendarId: "primary",
306
+ timeMin: now.toISOString(),
307
+ timeMax: timeMax.toISOString(),
308
+ maxResults: 10,
309
+ singleEvents: true,
310
+ orderBy: "startTime",
311
+ });
312
+
313
+ const events = response.data.items || [];
314
+ return events.map((event) => {
315
+ const isAllDay = !event.start?.dateTime;
316
+ let start: Date, end: Date;
317
+
318
+ if (isAllDay) {
319
+ start = new Date(event.start?.date + "T00:00:00");
320
+ end = new Date(event.end?.date + "T00:00:00");
321
+ } else {
322
+ start = new Date(event.start?.dateTime!);
323
+ end = new Date(event.end?.dateTime!);
324
+ }
325
+
326
+ return {
327
+ id: event.id || "",
328
+ title: event.summary || "(No title)",
329
+ start,
330
+ end,
331
+ isAllDay,
332
+ account: extractAccountName(account),
333
+ accountEmail: account,
334
+ accountIndex,
335
+ };
336
+ });
337
+ } catch {
338
+ return [];
339
+ }
340
+ });
341
+
342
+ const results = await Promise.all(eventPromises);
343
+ for (const events of results) {
344
+ allEvents.push(...events);
345
+ }
346
+
347
+ allEvents.sort((a, b) => a.start.getTime() - b.start.getTime());
348
+ return allEvents;
349
+ }
350
+
351
+ function extractAccountName(email: string): string {
352
+ const atIndex = email.indexOf("@");
353
+ if (atIndex === -1) return email;
354
+
355
+ const domain = email.slice(atIndex + 1);
356
+ if (domain === "gmail.com") {
357
+ return email.slice(0, atIndex);
358
+ }
359
+
360
+ return domain.split(".")[0];
361
+ }
362
+
363
+ function getCurrentOrNextEvent(events: CalendarEvent[]): CalendarEvent | null {
364
+ const now = new Date();
365
+
366
+ for (const event of events) {
367
+ if (event.start <= now && event.end > now) return event;
368
+ }
369
+
370
+ for (const event of events) {
371
+ if (event.start > now) return event;
372
+ }
373
+
374
+ return null;
375
+ }
376
+
377
+ // ============================================================================
378
+ // Formatter
379
+ // ============================================================================
380
+
381
+ function formatEvent(event: CalendarEvent, config: Config): string {
382
+ const now = new Date();
383
+ const isHappening = event.start <= now && event.end > now;
384
+ const minutesUntil = Math.round((event.start.getTime() - now.getTime()) / 60000);
385
+
386
+ let timeStr: string;
387
+ if (isHappening) {
388
+ timeStr = "Now";
389
+ } else if (minutesUntil <= config.countdownThresholdMinutes && minutesUntil > 0) {
390
+ timeStr = formatCountdown(minutesUntil);
391
+ } else {
392
+ timeStr = formatTime(event.start);
393
+ }
394
+
395
+ const title = event.title.length > config.maxTitleLength
396
+ ? event.title.slice(0, config.maxTitleLength - 1) + "…"
397
+ : event.title;
398
+
399
+ const colorName = ACCOUNT_COLORS[event.accountIndex % ACCOUNT_COLORS.length];
400
+ const color = COLORS[colorName] || COLORS.white;
401
+
402
+ if (config.showCalendarName) {
403
+ return `${color}${timeStr}: ${title} (${event.account})${COLORS.reset}`;
404
+ }
405
+ return `${color}${timeStr}: ${title}${COLORS.reset}`;
406
+ }
407
+
408
+ function formatCountdown(minutes: number): string {
409
+ if (minutes < 60) return `In ${minutes}m`;
410
+ const hours = Math.floor(minutes / 60);
411
+ const mins = minutes % 60;
412
+ return mins === 0 ? `In ${hours}h` : `In ${hours}h${mins}m`;
413
+ }
414
+
415
+ function formatTime(date: Date): string {
416
+ const hours = date.getHours();
417
+ const minutes = date.getMinutes();
418
+ const isPM = hours >= 12;
419
+ const hour12 = hours % 12 || 12;
420
+ return `${hour12}:${minutes.toString().padStart(2, "0")} ${isPM ? "PM" : "AM"}`;
421
+ }
422
+
423
+ // ============================================================================
424
+ // CLI Commands
425
+ // ============================================================================
426
+
427
+ function printHelp() {
428
+ console.log(`
429
+ glancebar - A customizable statusline for Claude Code
430
+
431
+ Display calendar events, tasks, and more at a glance.
432
+
433
+ Usage:
434
+ glancebar Output statusline (for Claude Code)
435
+ glancebar auth Authenticate all configured accounts
436
+ glancebar auth --add <email> Add and authenticate a new account
437
+ glancebar auth --remove <email> Remove an account
438
+ glancebar auth --list List configured accounts
439
+ glancebar config Show current configuration
440
+ glancebar config --lookahead <hours> Set lookahead hours (default: 8)
441
+ glancebar config --countdown-threshold <mins> Set countdown threshold in minutes (default: 60)
442
+ glancebar config --max-title <length> Set max title length (default: 120)
443
+ glancebar config --show-calendar <true|false> Show calendar name (default: true)
444
+ glancebar config --water-reminder <true|false> Enable/disable water reminders (default: true)
445
+ glancebar config --water-interval <mins> Set water reminder interval (default: 30)
446
+ glancebar config --reset Reset to default configuration
447
+ glancebar setup Show setup instructions
448
+
449
+ Examples:
450
+ glancebar auth --add user@gmail.com
451
+ glancebar config --lookahead 12
452
+ glancebar config --water-interval 45
453
+
454
+ Config location: ${getConfigDir()}
455
+ `);
456
+ }
457
+
458
+ function printSetup() {
459
+ console.log(`
460
+ Glancebar - Setup Instructions
461
+ ==============================
462
+
463
+ Step 1: Create Google Cloud Project
464
+ - Go to https://console.cloud.google.com/
465
+ - Create a new project or select existing one
466
+
467
+ Step 2: Enable Google Calendar API
468
+ - Go to "APIs & Services" > "Library"
469
+ - Search for "Google Calendar API" and enable it
470
+
471
+ Step 3: Create OAuth Credentials
472
+ - Go to "APIs & Services" > "Credentials"
473
+ - Click "Create Credentials" > "OAuth client ID"
474
+ - Select "Desktop app" as application type
475
+ - Download the JSON file
476
+
477
+ Step 4: Save credentials
478
+ - Rename downloaded file to "credentials.json"
479
+ - Save it to: ${getCredentialsPath()}
480
+
481
+ Step 5: Add redirect URI
482
+ - In Google Cloud Console, edit your OAuth client
483
+ - Add redirect URI: http://localhost:3000/callback
484
+
485
+ Step 6: Add your Google accounts
486
+ glancebar auth --add your-email@gmail.com
487
+ glancebar auth --add work@company.com
488
+
489
+ Step 7: Configure Claude Code statusline
490
+ Update ~/.claude/settings.json:
491
+ {
492
+ "statusLine": {
493
+ "type": "command",
494
+ "command": "bunx @naarang/glancebar",
495
+ "padding": 0
496
+ }
497
+ }
498
+
499
+ For more info: https://github.com/vishal-android-freak/glancebar
500
+ `);
501
+ }
502
+
503
+ async function handleAuth(args: string[]) {
504
+ // Handle --list
505
+ if (args.includes("--list")) {
506
+ const config = loadConfig();
507
+ if (config.accounts.length === 0) {
508
+ console.log("No accounts configured.");
509
+ } else {
510
+ console.log("Configured accounts:");
511
+ config.accounts.forEach((acc, i) => {
512
+ const tokenPath = getTokenPath(acc);
513
+ const status = existsSync(tokenPath) ? "authenticated" : "not authenticated";
514
+ console.log(` ${i + 1}. ${acc} (${status})`);
515
+ });
516
+ }
517
+ return;
518
+ }
519
+
520
+ // Handle --add
521
+ const addIndex = args.indexOf("--add");
522
+ if (addIndex !== -1) {
523
+ const email = args[addIndex + 1];
524
+ if (!email || email.startsWith("--")) {
525
+ console.error("Error: Please provide an email address after --add");
526
+ process.exit(1);
527
+ }
528
+
529
+ if (!email.includes("@")) {
530
+ console.error("Error: Invalid email address");
531
+ process.exit(1);
532
+ }
533
+
534
+ const config = loadConfig();
535
+ if (config.accounts.includes(email)) {
536
+ console.log(`Account ${email} already exists. Re-authenticating...`);
537
+ } else {
538
+ config.accounts.push(email);
539
+ saveConfig(config);
540
+ console.log(`Added ${email} to accounts.`);
541
+ }
542
+
543
+ await authenticateAccount(email);
544
+ console.log("\nDone!");
545
+ return;
546
+ }
547
+
548
+ // Handle --remove
549
+ const removeIndex = args.indexOf("--remove");
550
+ if (removeIndex !== -1) {
551
+ const email = args[removeIndex + 1];
552
+ if (!email || email.startsWith("--")) {
553
+ console.error("Error: Please provide an email address after --remove");
554
+ process.exit(1);
555
+ }
556
+
557
+ const config = loadConfig();
558
+ const idx = config.accounts.indexOf(email);
559
+ if (idx === -1) {
560
+ console.error(`Error: Account ${email} not found.`);
561
+ process.exit(1);
562
+ }
563
+
564
+ config.accounts.splice(idx, 1);
565
+ saveConfig(config);
566
+
567
+ const tokenPath = getTokenPath(email);
568
+ if (existsSync(tokenPath)) {
569
+ unlinkSync(tokenPath);
570
+ }
571
+
572
+ console.log(`Removed ${email} from accounts.`);
573
+ return;
574
+ }
575
+
576
+ // Default: authenticate all accounts
577
+ const config = loadConfig();
578
+
579
+ if (config.accounts.length === 0) {
580
+ console.log("No accounts configured.\n");
581
+ console.log("Add an account using:");
582
+ console.log(" glancebar auth --add your-email@gmail.com\n");
583
+ return;
584
+ }
585
+
586
+ console.log("Glancebar - Google Calendar Authentication");
587
+ console.log("==========================================\n");
588
+
589
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
590
+ const prompt = (q: string): Promise<string> => new Promise((r) => rl.question(q, r));
591
+
592
+ for (const account of config.accounts) {
593
+ const tokenPath = getTokenPath(account);
594
+ if (existsSync(tokenPath)) {
595
+ console.log(`${account}: Already authenticated`);
596
+ const answer = await prompt(`Re-authenticate ${account}? (y/N): `);
597
+ if (answer.toLowerCase() !== "y") continue;
598
+ }
599
+ await authenticateAccount(account);
600
+ }
601
+
602
+ rl.close();
603
+ console.log("\nAll accounts authenticated!");
604
+ }
605
+
606
+ function handleConfig(args: string[]) {
607
+ const config = loadConfig();
608
+
609
+ // Handle --reset
610
+ if (args.includes("--reset")) {
611
+ const accounts = config.accounts; // Preserve accounts
612
+ saveConfig({ ...DEFAULT_CONFIG, accounts });
613
+ console.log("Configuration reset to defaults (accounts preserved).");
614
+ return;
615
+ }
616
+
617
+ // Handle --lookahead
618
+ const lookaheadIndex = args.indexOf("--lookahead");
619
+ if (lookaheadIndex !== -1) {
620
+ const value = parseInt(args[lookaheadIndex + 1], 10);
621
+ if (isNaN(value) || value < 1 || value > 168) {
622
+ console.error("Error: lookahead must be between 1 and 168 hours");
623
+ process.exit(1);
624
+ }
625
+ config.lookaheadHours = value;
626
+ saveConfig(config);
627
+ console.log(`Lookahead hours set to ${value}`);
628
+ return;
629
+ }
630
+
631
+ // Handle --countdown-threshold
632
+ const countdownIndex = args.indexOf("--countdown-threshold");
633
+ if (countdownIndex !== -1) {
634
+ const value = parseInt(args[countdownIndex + 1], 10);
635
+ if (isNaN(value) || value < 0 || value > 1440) {
636
+ console.error("Error: countdown-threshold must be between 0 and 1440 minutes");
637
+ process.exit(1);
638
+ }
639
+ config.countdownThresholdMinutes = value;
640
+ saveConfig(config);
641
+ console.log(`Countdown threshold set to ${value} minutes`);
642
+ return;
643
+ }
644
+
645
+ // Handle --max-title
646
+ const maxTitleIndex = args.indexOf("--max-title");
647
+ if (maxTitleIndex !== -1) {
648
+ const value = parseInt(args[maxTitleIndex + 1], 10);
649
+ if (isNaN(value) || value < 10 || value > 500) {
650
+ console.error("Error: max-title must be between 10 and 500");
651
+ process.exit(1);
652
+ }
653
+ config.maxTitleLength = value;
654
+ saveConfig(config);
655
+ console.log(`Max title length set to ${value}`);
656
+ return;
657
+ }
658
+
659
+ // Handle --show-calendar
660
+ const showCalIndex = args.indexOf("--show-calendar");
661
+ if (showCalIndex !== -1) {
662
+ const value = args[showCalIndex + 1]?.toLowerCase();
663
+ if (value !== "true" && value !== "false") {
664
+ console.error("Error: --show-calendar must be 'true' or 'false'");
665
+ process.exit(1);
666
+ }
667
+ config.showCalendarName = value === "true";
668
+ saveConfig(config);
669
+ console.log(`Show calendar name set to ${value}`);
670
+ return;
671
+ }
672
+
673
+ // Handle --water-reminder
674
+ const waterReminderIndex = args.indexOf("--water-reminder");
675
+ if (waterReminderIndex !== -1) {
676
+ const value = args[waterReminderIndex + 1]?.toLowerCase();
677
+ if (value !== "true" && value !== "false") {
678
+ console.error("Error: --water-reminder must be 'true' or 'false'");
679
+ process.exit(1);
680
+ }
681
+ config.waterReminderEnabled = value === "true";
682
+ saveConfig(config);
683
+ console.log(`Water reminder ${value === "true" ? "enabled" : "disabled"}`);
684
+ return;
685
+ }
686
+
687
+ // Handle --water-interval
688
+ const waterIntervalIndex = args.indexOf("--water-interval");
689
+ if (waterIntervalIndex !== -1) {
690
+ const value = parseInt(args[waterIntervalIndex + 1], 10);
691
+ if (isNaN(value) || value < 5 || value > 120) {
692
+ console.error("Error: water-interval must be between 5 and 120 minutes");
693
+ process.exit(1);
694
+ }
695
+ config.waterReminderIntervalMinutes = value;
696
+ saveConfig(config);
697
+ console.log(`Water reminder interval set to ${value} minutes`);
698
+ return;
699
+ }
700
+
701
+ // Show current config
702
+ console.log(`
703
+ Glancebar Configuration
704
+ =======================
705
+ Config directory: ${getConfigDir()}
706
+ Accounts: ${config.accounts.length > 0 ? config.accounts.join(", ") : "(none)"}
707
+
708
+ Calendar Settings:
709
+ Lookahead hours: ${config.lookaheadHours}
710
+ Countdown threshold: ${config.countdownThresholdMinutes} minutes
711
+ Max title length: ${config.maxTitleLength}
712
+ Show calendar name: ${config.showCalendarName}
713
+
714
+ Reminders:
715
+ Water reminder: ${config.waterReminderEnabled ? "enabled" : "disabled"}
716
+ Water interval: ${config.waterReminderIntervalMinutes} minutes
717
+ `);
718
+ }
719
+
720
+ function shouldShowWaterReminder(config: Config): boolean {
721
+ if (!config.waterReminderEnabled) return false;
722
+
723
+ const now = new Date();
724
+ const minutes = now.getHours() * 60 + now.getMinutes();
725
+
726
+ // Show water reminder if current minute falls on the interval
727
+ return minutes % config.waterReminderIntervalMinutes === 0;
728
+ }
729
+
730
+ function getWaterReminder(): string {
731
+ const reminder = WATER_REMINDERS[Math.floor(Math.random() * WATER_REMINDERS.length)];
732
+ return `${COLORS.brightCyan}${reminder}${COLORS.reset}`;
733
+ }
734
+
735
+ async function outputStatusline() {
736
+ // Consume stdin (Claude Code sends JSON)
737
+ try {
738
+ for await (const _ of Bun.stdin.stream()) break;
739
+ } catch {}
740
+
741
+ try {
742
+ const config = loadConfig();
743
+ const parts: string[] = [];
744
+
745
+ // Check for water reminder first
746
+ if (shouldShowWaterReminder(config)) {
747
+ parts.push(getWaterReminder());
748
+ }
749
+
750
+ // Get calendar events
751
+ if (config.accounts.length > 0) {
752
+ const events = await getUpcomingEvents(config);
753
+ const event = getCurrentOrNextEvent(events);
754
+
755
+ if (event) {
756
+ parts.push(formatEvent(event, config));
757
+ } else if (parts.length === 0) {
758
+ parts.push("No upcoming events");
759
+ }
760
+ } else if (parts.length === 0) {
761
+ parts.push("No accounts configured");
762
+ }
763
+
764
+ console.log(parts.join(" | "));
765
+ } catch {
766
+ console.log("Calendar unavailable");
767
+ }
768
+ }
769
+
770
+ // ============================================================================
771
+ // Main
772
+ // ============================================================================
773
+
774
+ async function main() {
775
+ const args = process.argv.slice(2);
776
+ const command = args[0];
777
+
778
+ if (!command) {
779
+ // Default: output statusline
780
+ await outputStatusline();
781
+ return;
782
+ }
783
+
784
+ switch (command) {
785
+ case "help":
786
+ case "--help":
787
+ case "-h":
788
+ printHelp();
789
+ break;
790
+
791
+ case "setup":
792
+ printSetup();
793
+ break;
794
+
795
+ case "auth":
796
+ await handleAuth(args.slice(1));
797
+ break;
798
+
799
+ case "config":
800
+ handleConfig(args.slice(1));
801
+ break;
802
+
803
+ default:
804
+ console.error(`Unknown command: ${command}`);
805
+ console.error("Run 'glancebar --help' for usage.");
806
+ process.exit(1);
807
+ }
808
+ }
809
+
810
+ main().catch((err) => {
811
+ console.error("Error:", err.message);
812
+ process.exit(1);
813
+ });