├── .gitignore ├── src ├── types.ts ├── account-storage.ts ├── calendar-oauth-flow.ts ├── calendar-service.ts └── cli.ts ├── tsconfig.json ├── CHANGELOG.md ├── .husky └── pre-commit ├── .github └── workflows │ └── ci.yml ├── biome.json ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .env 4 | .env.local 5 | *.log 6 | .DS_Store 7 | .mailcp/ -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface CalendarAccount { 2 | email: string; 3 | oauth2: { 4 | clientId: string; 5 | clientSecret: string; 6 | refreshToken: string; 7 | accessToken?: string; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "lib": ["ES2022"], 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "declaration": true, 11 | "declarationMap": true, 12 | "sourceMap": true, 13 | "moduleResolution": "Node16", 14 | "resolveJsonModule": true, 15 | "outDir": "dist", 16 | "rootDir": "src", 17 | "types": ["node"] 18 | }, 19 | "include": ["src/**/*"], 20 | "exclude": ["node_modules", "dist"] 21 | } 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.1.2] - 2025-12-12 4 | 5 | ### Fixed 6 | 7 | - Document `acl` command in README 8 | 9 | ## [0.1.1] - 2025-12-12 10 | 11 | ### Added 12 | 13 | - `acl` command to list calendar access control rules 14 | 15 | ### Fixed 16 | 17 | - Handle both Google download format and gmcli credential format 18 | 19 | ## [0.1.0] - 2025-12-12 20 | 21 | ### Added 22 | 23 | - Initial release 24 | - Account management (credentials, add, remove, list) 25 | - List calendars 26 | - List, get, create, update, delete events 27 | - Free/busy queries 28 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Get list of staged files before running check 4 | STAGED_FILES=$(git diff --cached --name-only) 5 | 6 | # Run the check script (formatting, linting, and type checking) 7 | echo "Running formatting, linting, and type checking..." 8 | npm run check 9 | if [ $? -ne 0 ]; then 10 | echo "❌ Checks failed. Please fix the errors before committing." 11 | exit 1 12 | fi 13 | 14 | # Restage files that were previously staged and may have been modified by formatting 15 | for file in $STAGED_FILES; do 16 | if [ -f "$file" ]; then 17 | git add "$file" 18 | fi 19 | done 20 | 21 | echo "✅ All pre-commit checks passed!" 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | concurrency: 10 | group: ci-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build-check: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 22 24 | cache: npm 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Build 30 | run: npm run build 31 | 32 | - name: Check 33 | run: npm run check 34 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.3.5/schema.json", 3 | "linter": { 4 | "enabled": true, 5 | "rules": { 6 | "recommended": true, 7 | "style": { 8 | "noNonNullAssertion": "off", 9 | "useConst": "error", 10 | "useNodejsImportProtocol": "off", 11 | "useTemplate": "off" 12 | }, 13 | "correctness": { 14 | "noUnusedVariables": "off" 15 | }, 16 | "suspicious": { 17 | "noExplicitAny": "off", 18 | "noControlCharactersInRegex": "off", 19 | "noEmptyInterface": "off" 20 | } 21 | } 22 | }, 23 | "formatter": { 24 | "enabled": true, 25 | "formatWithErrors": false, 26 | "indentStyle": "tab", 27 | "indentWidth": 3, 28 | "lineWidth": 120 29 | }, 30 | "files": { 31 | "include": ["src/**/*", "*.json", "*.md"], 32 | "ignore": ["node_modules", "dist"] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mariozechner/gccli", 3 | "version": "0.1.2", 4 | "description": "Minimal Google Calendar CLI", 5 | "type": "module", 6 | "bin": { 7 | "gccli": "dist/cli.js" 8 | }, 9 | "main": "./dist/calendar-service.js", 10 | "types": "./dist/calendar-service.d.ts", 11 | "files": [ 12 | "dist" 13 | ], 14 | "scripts": { 15 | "build": "tsc && chmod +x dist/cli.js", 16 | "check": "biome check src/ --write --diagnostic-level=error && tsgo --noEmit", 17 | "prepublishOnly": "npm run build", 18 | "prepare": "husky" 19 | }, 20 | "dependencies": { 21 | "google-auth-library": "^10.1.0", 22 | "googleapis": "^153.0.0" 23 | }, 24 | "devDependencies": { 25 | "@biomejs/biome": "^1.9.4", 26 | "@types/node": "^22.10.1", 27 | "@typescript/native-preview": "^7.0.0-dev.20251204.1", 28 | "husky": "^9.1.7", 29 | "typescript": "^5.7.2" 30 | }, 31 | "keywords": [ 32 | "google-calendar", 33 | "calendar", 34 | "cli" 35 | ], 36 | "author": "", 37 | "license": "MIT", 38 | "engines": { 39 | "node": ">=20.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/account-storage.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as os from "os"; 3 | import * as path from "path"; 4 | import type { CalendarAccount } from "./types.js"; 5 | 6 | const CONFIG_DIR = path.join(os.homedir(), ".gccli"); 7 | const ACCOUNTS_FILE = path.join(CONFIG_DIR, "accounts.json"); 8 | const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json"); 9 | 10 | export class AccountStorage { 11 | private accounts: Map = new Map(); 12 | 13 | constructor() { 14 | this.ensureConfigDir(); 15 | this.loadAccounts(); 16 | } 17 | 18 | private ensureConfigDir(): void { 19 | if (!fs.existsSync(CONFIG_DIR)) { 20 | fs.mkdirSync(CONFIG_DIR, { recursive: true }); 21 | } 22 | } 23 | 24 | private loadAccounts(): void { 25 | if (fs.existsSync(ACCOUNTS_FILE)) { 26 | try { 27 | const data = JSON.parse(fs.readFileSync(ACCOUNTS_FILE, "utf8")); 28 | for (const account of data) { 29 | this.accounts.set(account.email, account); 30 | } 31 | } catch { 32 | // Ignore 33 | } 34 | } 35 | } 36 | 37 | private saveAccounts(): void { 38 | fs.writeFileSync(ACCOUNTS_FILE, JSON.stringify(Array.from(this.accounts.values()), null, 2)); 39 | } 40 | 41 | addAccount(account: CalendarAccount): void { 42 | this.accounts.set(account.email, account); 43 | this.saveAccounts(); 44 | } 45 | 46 | getAccount(email: string): CalendarAccount | undefined { 47 | return this.accounts.get(email); 48 | } 49 | 50 | getAllAccounts(): CalendarAccount[] { 51 | return Array.from(this.accounts.values()); 52 | } 53 | 54 | deleteAccount(email: string): boolean { 55 | const deleted = this.accounts.delete(email); 56 | if (deleted) this.saveAccounts(); 57 | return deleted; 58 | } 59 | 60 | hasAccount(email: string): boolean { 61 | return this.accounts.has(email); 62 | } 63 | 64 | setCredentials(clientId: string, clientSecret: string): void { 65 | fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify({ clientId, clientSecret }, null, 2)); 66 | } 67 | 68 | getCredentials(): { clientId: string; clientSecret: string } | null { 69 | if (!fs.existsSync(CREDENTIALS_FILE)) return null; 70 | try { 71 | return JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf8")); 72 | } catch { 73 | return null; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gccli 2 | 3 | Minimal Google Calendar CLI for listing calendars, managing events, and checking availability. 4 | 5 | ## Install 6 | 7 | ```bash 8 | npm install -g @mariozechner/gccli 9 | ``` 10 | 11 | ## Setup 12 | 13 | Before adding an account, you need OAuth2 credentials from Google Cloud Console: 14 | 15 | 1. [Create a new project](https://console.cloud.google.com/projectcreate) (or select existing) 16 | 2. [Enable the Google Calendar API](https://console.cloud.google.com/apis/api/calendar-json.googleapis.com) 17 | 3. [Set app name](https://console.cloud.google.com/auth/branding) in OAuth branding 18 | 4. [Add test users](https://console.cloud.google.com/auth/audience) (all Gmail addresses you want to use with gccli) 19 | 5. [Create OAuth client](https://console.cloud.google.com/auth/clients): 20 | - Click "Create Client" 21 | - Application type: "Desktop app" 22 | - Download the JSON file 23 | 24 | Then: 25 | 26 | ```bash 27 | gccli accounts credentials ~/path/to/credentials.json 28 | gccli accounts add you@gmail.com 29 | ``` 30 | 31 | ## Usage 32 | 33 | ``` 34 | gccli accounts Account management 35 | gccli [options] Calendar operations 36 | ``` 37 | 38 | ## Commands 39 | 40 | ### accounts 41 | 42 | ```bash 43 | gccli accounts credentials # Set OAuth credentials (once) 44 | gccli accounts list # List configured accounts 45 | gccli accounts add # Add account (opens browser) 46 | gccli accounts add --manual # Add account (browserless, paste redirect URL) 47 | gccli accounts remove # Remove account 48 | ``` 49 | 50 | ### calendars 51 | 52 | List all calendars for an account. 53 | 54 | ```bash 55 | gccli calendars 56 | ``` 57 | 58 | Returns: ID, name, access role. 59 | 60 | ### events 61 | 62 | List events from a calendar. 63 | 64 | ```bash 65 | gccli events [options] 66 | ``` 67 | 68 | Options: 69 | - `--from ` - Start time (ISO 8601, default: now) 70 | - `--to ` - End time (ISO 8601, default: 1 week from now) 71 | - `--max ` - Max results (default: 10) 72 | - `--page ` - Page token for pagination 73 | - `--query ` - Free text search 74 | 75 | Examples: 76 | ```bash 77 | gccli you@gmail.com events primary 78 | gccli you@gmail.com events primary --from 2024-01-01T00:00:00Z --max 50 79 | gccli you@gmail.com events primary --query "meeting" 80 | ``` 81 | 82 | ### event 83 | 84 | Get details for a specific event. 85 | 86 | ```bash 87 | gccli event 88 | ``` 89 | 90 | ### create 91 | 92 | Create a new event. 93 | 94 | ```bash 95 | gccli create --summary --start
--end
[options] 96 | ``` 97 | 98 | Options: 99 | - `--summary ` - Event title (required) 100 | - `--start ` - Start time (required, ISO 8601) 101 | - `--end ` - End time (required, ISO 8601) 102 | - `--description ` - Event description 103 | - `--location ` - Event location 104 | - `--attendees ` - Attendees (comma-separated) 105 | - `--all-day` - Create all-day event (use YYYY-MM-DD for start/end) 106 | 107 | Examples: 108 | ```bash 109 | gccli you@gmail.com create primary --summary "Meeting" --start 2024-01-15T10:00:00 --end 2024-01-15T11:00:00 110 | gccli you@gmail.com create primary --summary "Vacation" --start 2024-01-20 --end 2024-01-25 --all-day 111 | gccli you@gmail.com create primary --summary "Team Sync" --start 2024-01-15T14:00:00 --end 2024-01-15T15:00:00 --attendees a@x.com,b@x.com 112 | ``` 113 | 114 | ### update 115 | 116 | Update an existing event. 117 | 118 | ```bash 119 | gccli update [options] 120 | ``` 121 | 122 | Options: same as create (all optional). 123 | 124 | Example: 125 | ```bash 126 | gccli you@gmail.com update primary abc123 --summary "Updated Meeting" --location "Room 2" 127 | ``` 128 | 129 | ### delete 130 | 131 | Delete an event. 132 | 133 | ```bash 134 | gccli delete 135 | ``` 136 | 137 | ### freebusy 138 | 139 | Check free/busy status for calendars. 140 | 141 | ```bash 142 | gccli freebusy --from
--to
143 | ``` 144 | 145 | Calendar IDs are comma-separated. 146 | 147 | Example: 148 | ```bash 149 | gccli you@gmail.com freebusy primary,work@group.calendar.google.com --from 2024-01-15T00:00:00Z --to 2024-01-16T00:00:00Z 150 | ``` 151 | 152 | ### acl 153 | 154 | List access control rules for a calendar. 155 | 156 | ```bash 157 | gccli acl 158 | ``` 159 | 160 | Returns: scope type, scope value, role. 161 | 162 | Example: 163 | ```bash 164 | gccli you@gmail.com acl primary 165 | ``` 166 | 167 | ## Data Storage 168 | 169 | All data is stored in `~/.gccli/`: 170 | - `credentials.json` - OAuth client credentials 171 | - `accounts.json` - Account tokens 172 | 173 | ## Development 174 | 175 | ```bash 176 | npm install 177 | npm run build 178 | npm run check 179 | ``` 180 | 181 | ## Publishing 182 | 183 | ```bash 184 | # Update version in package.json and CHANGELOG.md 185 | npm run build 186 | npm publish --access public 187 | git tag v 188 | git push --tags 189 | ``` 190 | 191 | ## License 192 | 193 | MIT 194 | -------------------------------------------------------------------------------- /src/calendar-oauth-flow.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | import * as http from "http"; 3 | import type { AddressInfo } from "net"; 4 | import * as readline from "readline"; 5 | import * as url from "url"; 6 | import { OAuth2Client } from "google-auth-library"; 7 | 8 | const SCOPES = ["https://www.googleapis.com/auth/calendar"]; 9 | const TIMEOUT_MS = 2 * 60 * 1000; 10 | 11 | interface AuthResult { 12 | success: boolean; 13 | refreshToken?: string; 14 | error?: string; 15 | } 16 | 17 | export class CalendarOAuthFlow { 18 | private oauth2Client: OAuth2Client; 19 | private server: http.Server | null = null; 20 | private timeoutId: NodeJS.Timeout | null = null; 21 | 22 | constructor(clientId: string, clientSecret: string) { 23 | this.oauth2Client = new OAuth2Client(clientId, clientSecret); 24 | } 25 | 26 | async authorize(manual = false): Promise { 27 | const result = manual ? await this.startManualFlow() : await this.startAuthFlow(); 28 | if (!result.success) { 29 | throw new Error(result.error || "Authorization failed"); 30 | } 31 | if (!result.refreshToken) { 32 | throw new Error("No refresh token received"); 33 | } 34 | return result.refreshToken; 35 | } 36 | 37 | private async startManualFlow(): Promise { 38 | const redirectUri = "http://localhost:1"; 39 | this.oauth2Client = new OAuth2Client(this.oauth2Client._clientId, this.oauth2Client._clientSecret, redirectUri); 40 | 41 | const authUrl = this.oauth2Client.generateAuthUrl({ 42 | access_type: "offline", 43 | scope: SCOPES, 44 | }); 45 | 46 | console.log("Visit this URL to authorize:"); 47 | console.log(authUrl); 48 | console.log(""); 49 | console.log("After authorizing, you'll be redirected to a page that won't load."); 50 | console.log("Copy the URL from your browser's address bar and paste it here."); 51 | console.log(""); 52 | 53 | const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); 54 | 55 | return new Promise((resolve) => { 56 | rl.question("Paste redirect URL: ", async (input) => { 57 | rl.close(); 58 | try { 59 | const parsed = url.parse(input, true); 60 | const code = parsed.query.code as string; 61 | if (!code) { 62 | resolve({ success: false, error: "No authorization code found in URL" }); 63 | return; 64 | } 65 | const { tokens } = await this.oauth2Client.getToken(code); 66 | resolve({ success: true, refreshToken: tokens.refresh_token || undefined }); 67 | } catch (e) { 68 | resolve({ success: false, error: e instanceof Error ? e.message : String(e) }); 69 | } 70 | }); 71 | }); 72 | } 73 | 74 | private startAuthFlow(): Promise { 75 | return new Promise((resolve) => { 76 | this.server = http.createServer((req, res) => { 77 | const parsed = url.parse(req.url!, true); 78 | if (parsed.pathname === "/") { 79 | this.handleCallback(parsed.query, res, resolve); 80 | } else { 81 | res.writeHead(404); 82 | res.end(); 83 | } 84 | }); 85 | 86 | this.server.listen(0, "localhost", () => { 87 | const port = (this.server!.address() as AddressInfo).port; 88 | const redirectUri = `http://localhost:${port}`; 89 | 90 | this.oauth2Client = new OAuth2Client( 91 | this.oauth2Client._clientId, 92 | this.oauth2Client._clientSecret, 93 | redirectUri, 94 | ); 95 | 96 | const authUrl = this.oauth2Client.generateAuthUrl({ 97 | access_type: "offline", 98 | scope: SCOPES, 99 | }); 100 | 101 | console.log("Opening browser for Google Calendar authorization..."); 102 | console.log("If browser doesn't open, visit this URL:"); 103 | console.log(authUrl); 104 | this.openBrowser(authUrl); 105 | 106 | this.timeoutId = setTimeout(() => { 107 | console.log("Authorization timed out after 2 minutes"); 108 | this.cleanup(); 109 | resolve({ success: false, error: "Authorization timed out" }); 110 | }, TIMEOUT_MS); 111 | }); 112 | 113 | this.server.on("error", (err) => { 114 | this.cleanup(); 115 | resolve({ success: false, error: err.message }); 116 | }); 117 | }); 118 | } 119 | 120 | private async handleCallback( 121 | query: any, 122 | res: http.ServerResponse, 123 | resolve: (result: AuthResult) => void, 124 | ): Promise { 125 | if (query.error) { 126 | res.writeHead(200, { "Content-Type": "text/html" }); 127 | res.end("

Authorization cancelled

"); 128 | this.cleanup(); 129 | resolve({ success: false, error: query.error }); 130 | return; 131 | } 132 | 133 | if (!query.code) { 134 | res.writeHead(400, { "Content-Type": "text/html" }); 135 | res.end("

No authorization code

"); 136 | this.cleanup(); 137 | resolve({ success: false, error: "No authorization code" }); 138 | return; 139 | } 140 | 141 | try { 142 | const { tokens } = await this.oauth2Client.getToken(query.code as string); 143 | res.writeHead(200, { "Content-Type": "text/html" }); 144 | res.end("

Success!

You can close this window.

"); 145 | this.cleanup(); 146 | resolve({ success: true, refreshToken: tokens.refresh_token || undefined }); 147 | } catch (e) { 148 | res.writeHead(500, { "Content-Type": "text/html" }); 149 | res.end(`

Error

${e instanceof Error ? e.message : e}

`); 150 | this.cleanup(); 151 | resolve({ success: false, error: e instanceof Error ? e.message : String(e) }); 152 | } 153 | } 154 | 155 | private cleanup(): void { 156 | if (this.timeoutId) { 157 | clearTimeout(this.timeoutId); 158 | this.timeoutId = null; 159 | } 160 | if (this.server) { 161 | this.server.close(); 162 | this.server = null; 163 | } 164 | } 165 | 166 | private openBrowser(url: string): void { 167 | const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; 168 | spawn(cmd, [url], { detached: true, stdio: "ignore" }); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/calendar-service.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2Client } from "google-auth-library"; 2 | import { type calendar_v3, google } from "googleapis"; 3 | import { AccountStorage } from "./account-storage.js"; 4 | import { CalendarOAuthFlow } from "./calendar-oauth-flow.js"; 5 | import type { CalendarAccount } from "./types.js"; 6 | 7 | type CalendarEvent = calendar_v3.Schema$Event; 8 | type Calendar = calendar_v3.Schema$CalendarListEntry; 9 | 10 | export interface EventSearchResult { 11 | events: CalendarEvent[]; 12 | nextPageToken?: string; 13 | } 14 | 15 | export class CalendarService { 16 | private accountStorage = new AccountStorage(); 17 | private calendarClients: Map = new Map(); 18 | 19 | async addAccount(email: string, clientId: string, clientSecret: string, manual = false): Promise { 20 | if (this.accountStorage.hasAccount(email)) { 21 | throw new Error(`Account '${email}' already exists`); 22 | } 23 | 24 | const oauthFlow = new CalendarOAuthFlow(clientId, clientSecret); 25 | const refreshToken = await oauthFlow.authorize(manual); 26 | 27 | const account: CalendarAccount = { 28 | email, 29 | oauth2: { clientId, clientSecret, refreshToken }, 30 | }; 31 | 32 | this.accountStorage.addAccount(account); 33 | } 34 | 35 | deleteAccount(email: string): boolean { 36 | this.calendarClients.delete(email); 37 | return this.accountStorage.deleteAccount(email); 38 | } 39 | 40 | listAccounts(): CalendarAccount[] { 41 | return this.accountStorage.getAllAccounts(); 42 | } 43 | 44 | setCredentials(clientId: string, clientSecret: string): void { 45 | this.accountStorage.setCredentials(clientId, clientSecret); 46 | } 47 | 48 | getCredentials(): { clientId: string; clientSecret: string } | null { 49 | return this.accountStorage.getCredentials(); 50 | } 51 | 52 | private getCalendarClient(email: string): calendar_v3.Calendar { 53 | if (!this.calendarClients.has(email)) { 54 | const account = this.accountStorage.getAccount(email); 55 | if (!account) { 56 | throw new Error(`Account '${email}' not found`); 57 | } 58 | 59 | const oauth2Client = new OAuth2Client( 60 | account.oauth2.clientId, 61 | account.oauth2.clientSecret, 62 | "http://localhost", 63 | ); 64 | 65 | oauth2Client.setCredentials({ 66 | refresh_token: account.oauth2.refreshToken, 67 | access_token: account.oauth2.accessToken, 68 | }); 69 | 70 | const calendar = google.calendar({ version: "v3", auth: oauth2Client }); 71 | this.calendarClients.set(email, calendar); 72 | } 73 | 74 | return this.calendarClients.get(email)!; 75 | } 76 | 77 | async listCalendars(email: string): Promise { 78 | const calendar = this.getCalendarClient(email); 79 | const response = await calendar.calendarList.list(); 80 | return response.data.items || []; 81 | } 82 | 83 | async getCalendarAcl(email: string, calendarId: string): Promise { 84 | const calendar = this.getCalendarClient(email); 85 | const response = await calendar.acl.list({ calendarId }); 86 | return response.data.items || []; 87 | } 88 | 89 | async listEvents( 90 | email: string, 91 | calendarId: string, 92 | options: { 93 | timeMin?: string; 94 | timeMax?: string; 95 | maxResults?: number; 96 | pageToken?: string; 97 | query?: string; 98 | } = {}, 99 | ): Promise { 100 | const calendar = this.getCalendarClient(email); 101 | const response = await calendar.events.list({ 102 | calendarId, 103 | timeMin: options.timeMin, 104 | timeMax: options.timeMax, 105 | maxResults: options.maxResults || 10, 106 | pageToken: options.pageToken, 107 | q: options.query, 108 | singleEvents: true, 109 | orderBy: "startTime", 110 | }); 111 | 112 | return { 113 | events: response.data.items || [], 114 | nextPageToken: response.data.nextPageToken || undefined, 115 | }; 116 | } 117 | 118 | async getEvent(email: string, calendarId: string, eventId: string): Promise { 119 | const calendar = this.getCalendarClient(email); 120 | const response = await calendar.events.get({ 121 | calendarId, 122 | eventId, 123 | }); 124 | return response.data; 125 | } 126 | 127 | async createEvent( 128 | email: string, 129 | calendarId: string, 130 | event: { 131 | summary: string; 132 | description?: string; 133 | location?: string; 134 | start: string; 135 | end: string; 136 | attendees?: string[]; 137 | allDay?: boolean; 138 | }, 139 | ): Promise { 140 | const calendar = this.getCalendarClient(email); 141 | 142 | const eventBody: calendar_v3.Schema$Event = { 143 | summary: event.summary, 144 | description: event.description, 145 | location: event.location, 146 | start: event.allDay ? { date: event.start } : { dateTime: event.start }, 147 | end: event.allDay ? { date: event.end } : { dateTime: event.end }, 148 | attendees: event.attendees?.map((e) => ({ email: e })), 149 | }; 150 | 151 | const response = await calendar.events.insert({ 152 | calendarId, 153 | requestBody: eventBody, 154 | }); 155 | 156 | return response.data; 157 | } 158 | 159 | async updateEvent( 160 | email: string, 161 | calendarId: string, 162 | eventId: string, 163 | updates: { 164 | summary?: string; 165 | description?: string; 166 | location?: string; 167 | start?: string; 168 | end?: string; 169 | attendees?: string[]; 170 | allDay?: boolean; 171 | }, 172 | ): Promise { 173 | const calendar = this.getCalendarClient(email); 174 | 175 | // Get existing event first 176 | const existing = await this.getEvent(email, calendarId, eventId); 177 | 178 | const eventBody: calendar_v3.Schema$Event = { 179 | ...existing, 180 | summary: updates.summary ?? existing.summary, 181 | description: updates.description ?? existing.description, 182 | location: updates.location ?? existing.location, 183 | }; 184 | 185 | if (updates.start !== undefined) { 186 | eventBody.start = updates.allDay ? { date: updates.start } : { dateTime: updates.start }; 187 | } 188 | if (updates.end !== undefined) { 189 | eventBody.end = updates.allDay ? { date: updates.end } : { dateTime: updates.end }; 190 | } 191 | if (updates.attendees !== undefined) { 192 | eventBody.attendees = updates.attendees.map((e) => ({ email: e })); 193 | } 194 | 195 | const response = await calendar.events.update({ 196 | calendarId, 197 | eventId, 198 | requestBody: eventBody, 199 | }); 200 | 201 | return response.data; 202 | } 203 | 204 | async deleteEvent(email: string, calendarId: string, eventId: string): Promise { 205 | const calendar = this.getCalendarClient(email); 206 | await calendar.events.delete({ 207 | calendarId, 208 | eventId, 209 | }); 210 | } 211 | 212 | async getFreeBusy( 213 | email: string, 214 | calendarIds: string[], 215 | timeMin: string, 216 | timeMax: string, 217 | ): Promise>> { 218 | const calendar = this.getCalendarClient(email); 219 | const response = await calendar.freebusy.query({ 220 | requestBody: { 221 | timeMin, 222 | timeMax, 223 | items: calendarIds.map((id) => ({ id })), 224 | }, 225 | }); 226 | 227 | const result = new Map>(); 228 | const calendars = response.data.calendars || {}; 229 | 230 | for (const [calId, data] of Object.entries(calendars)) { 231 | const busy = (data.busy || []).map((b) => ({ 232 | start: b.start || "", 233 | end: b.end || "", 234 | })); 235 | result.set(calId, busy); 236 | } 237 | 238 | return result; 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as fs from "fs"; 4 | import { parseArgs } from "util"; 5 | import { CalendarService } from "./calendar-service.js"; 6 | 7 | const service = new CalendarService(); 8 | 9 | function usage(): never { 10 | console.log(`gccli - Google Calendar CLI 11 | 12 | USAGE 13 | 14 | gccli accounts Account management 15 | gccli [options] Calendar operations 16 | 17 | ACCOUNT COMMANDS 18 | 19 | gccli accounts credentials Set OAuth credentials (once) 20 | gccli accounts list List configured accounts 21 | gccli accounts add [--manual] Add account (--manual for browserless OAuth) 22 | gccli accounts remove Remove account 23 | 24 | CALENDAR COMMANDS 25 | 26 | gccli calendars 27 | List all calendars. Returns: ID, name, access role. 28 | 29 | gccli acl 30 | List access control rules (who has access to the calendar). 31 | 32 | gccli events [options] 33 | List events from a calendar. 34 | Options: 35 | --from Start time (ISO 8601, default: now) 36 | --to End time (ISO 8601, default: 1 week from now) 37 | --max Max results (default: 10) 38 | --page Page token for pagination 39 | --query Free text search 40 | 41 | gccli event 42 | Get event details. 43 | 44 | gccli create --summary --start
--end
[options] 45 | Create a new event. 46 | Options: 47 | --summary Event title (required) 48 | --start Start time (required, ISO 8601) 49 | --end End time (required, ISO 8601) 50 | --description Event description 51 | --location Event location 52 | --attendees Attendees (comma-separated) 53 | --all-day Create all-day event (use YYYY-MM-DD for start/end) 54 | 55 | gccli update [options] 56 | Update an existing event. 57 | Options: same as create (all optional) 58 | 59 | gccli delete 60 | Delete an event. 61 | 62 | gccli freebusy --from
--to
63 | Check free/busy status for calendars (comma-separated IDs). 64 | 65 | EXAMPLES 66 | 67 | gccli accounts list 68 | gccli you@gmail.com calendars 69 | gccli you@gmail.com events primary 70 | gccli you@gmail.com events primary --from 2024-01-01T00:00:00Z --max 50 71 | gccli you@gmail.com event primary abc123 72 | gccli you@gmail.com create primary --summary "Meeting" --start 2024-01-15T10:00:00 --end 2024-01-15T11:00:00 73 | gccli you@gmail.com create primary --summary "Vacation" --start 2024-01-20 --end 2024-01-25 --all-day 74 | gccli you@gmail.com update primary abc123 --summary "Updated Meeting" 75 | gccli you@gmail.com delete primary abc123 76 | gccli you@gmail.com freebusy primary,work@group.calendar.google.com --from 2024-01-15T00:00:00Z --to 2024-01-16T00:00:00Z 77 | 78 | DATA STORAGE 79 | 80 | ~/.gccli/credentials.json OAuth client credentials 81 | ~/.gccli/accounts.json Account tokens`); 82 | process.exit(1); 83 | } 84 | 85 | function error(msg: string): never { 86 | console.error("Error:", msg); 87 | process.exit(1); 88 | } 89 | 90 | async function main() { 91 | const args = process.argv.slice(2); 92 | if (args.length === 0 || args.includes("--help") || args.includes("-h")) { 93 | usage(); 94 | } 95 | 96 | const first = args[0]; 97 | const rest = args.slice(1); 98 | 99 | try { 100 | if (first === "accounts") { 101 | await handleAccounts(rest); 102 | return; 103 | } 104 | 105 | const account = first; 106 | const command = rest[0]; 107 | const commandArgs = rest.slice(1); 108 | 109 | if (!command) { 110 | error("Missing command. Use --help for usage."); 111 | } 112 | 113 | switch (command) { 114 | case "calendars": 115 | await handleCalendars(account); 116 | break; 117 | case "acl": 118 | await handleAcl(account, commandArgs); 119 | break; 120 | case "events": 121 | await handleEvents(account, commandArgs); 122 | break; 123 | case "event": 124 | await handleEvent(account, commandArgs); 125 | break; 126 | case "create": 127 | await handleCreate(account, commandArgs); 128 | break; 129 | case "update": 130 | await handleUpdate(account, commandArgs); 131 | break; 132 | case "delete": 133 | await handleDelete(account, commandArgs); 134 | break; 135 | case "freebusy": 136 | await handleFreeBusy(account, commandArgs); 137 | break; 138 | default: 139 | error(`Unknown command: ${command}`); 140 | } 141 | } catch (e) { 142 | error(e instanceof Error ? e.message : String(e)); 143 | } 144 | } 145 | 146 | async function handleAccounts(args: string[]) { 147 | const action = args[0]; 148 | if (!action) error("Missing action: list|add|remove|credentials"); 149 | 150 | switch (action) { 151 | case "list": { 152 | const accounts = service.listAccounts(); 153 | if (accounts.length === 0) { 154 | console.log("No accounts configured"); 155 | } else { 156 | for (const a of accounts) { 157 | console.log(a.email); 158 | } 159 | } 160 | break; 161 | } 162 | case "credentials": { 163 | const credFile = args[1]; 164 | if (!credFile) error("Usage: accounts credentials "); 165 | const creds = JSON.parse(fs.readFileSync(credFile, "utf8")); 166 | // Handle Google's download format or gmcli's stored format 167 | const installed = creds.installed || creds.web; 168 | const clientId = installed?.client_id || creds.clientId; 169 | const clientSecret = installed?.client_secret || creds.clientSecret; 170 | if (!clientId || !clientSecret) error("Invalid credentials file"); 171 | service.setCredentials(clientId, clientSecret); 172 | console.log("Credentials saved"); 173 | break; 174 | } 175 | case "add": { 176 | const manual = args.includes("--manual"); 177 | const filtered = args.slice(1).filter((a) => a !== "--manual"); 178 | const email = filtered[0]; 179 | if (!email) error("Usage: accounts add [--manual]"); 180 | const creds = service.getCredentials(); 181 | if (!creds) error("No credentials configured. Run: gccli accounts credentials "); 182 | await service.addAccount(email, creds.clientId, creds.clientSecret, manual); 183 | console.log(`Account '${email}' added`); 184 | break; 185 | } 186 | case "remove": { 187 | const email = args[1]; 188 | if (!email) error("Usage: accounts remove "); 189 | const deleted = service.deleteAccount(email); 190 | console.log(deleted ? `Removed '${email}'` : `Not found: ${email}`); 191 | break; 192 | } 193 | default: 194 | error(`Unknown action: ${action}`); 195 | } 196 | } 197 | 198 | async function handleCalendars(account: string) { 199 | const calendars = await service.listCalendars(account); 200 | if (calendars.length === 0) { 201 | console.log("No calendars"); 202 | } else { 203 | console.log("ID\tNAME\tROLE"); 204 | for (const c of calendars) { 205 | console.log(`${c.id}\t${c.summary || ""}\t${c.accessRole || ""}`); 206 | } 207 | } 208 | } 209 | 210 | async function handleAcl(account: string, args: string[]) { 211 | const calendarId = args[0]; 212 | if (!calendarId) error("Usage: acl "); 213 | 214 | const rules = await service.getCalendarAcl(account, calendarId); 215 | if (rules.length === 0) { 216 | console.log("No ACL rules"); 217 | } else { 218 | console.log("SCOPE_TYPE\tSCOPE_VALUE\tROLE"); 219 | for (const rule of rules) { 220 | const scopeType = rule.scope?.type || ""; 221 | const scopeValue = rule.scope?.value || ""; 222 | const role = rule.role || ""; 223 | console.log(`${scopeType}\t${scopeValue}\t${role}`); 224 | } 225 | } 226 | } 227 | 228 | async function handleEvents(account: string, args: string[]) { 229 | const { values, positionals } = parseArgs({ 230 | args, 231 | options: { 232 | from: { type: "string" }, 233 | to: { type: "string" }, 234 | max: { type: "string" }, 235 | page: { type: "string" }, 236 | query: { type: "string" }, 237 | }, 238 | allowPositionals: true, 239 | }); 240 | 241 | const calendarId = positionals[0]; 242 | if (!calendarId) error("Usage: events [options]"); 243 | 244 | const now = new Date(); 245 | const oneWeekLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); 246 | 247 | const result = await service.listEvents(account, calendarId, { 248 | timeMin: values.from || now.toISOString(), 249 | timeMax: values.to || oneWeekLater.toISOString(), 250 | maxResults: values.max ? Number(values.max) : 10, 251 | pageToken: values.page, 252 | query: values.query, 253 | }); 254 | 255 | if (result.events.length === 0) { 256 | console.log("No events"); 257 | } else { 258 | console.log("ID\tSTART\tEND\tSUMMARY"); 259 | for (const e of result.events) { 260 | const start = e.start?.dateTime || e.start?.date || ""; 261 | const end = e.end?.dateTime || e.end?.date || ""; 262 | console.log(`${e.id}\t${start}\t${end}\t${e.summary || "(no title)"}`); 263 | } 264 | if (result.nextPageToken) { 265 | console.log(`\n# Next page: --page ${result.nextPageToken}`); 266 | } 267 | } 268 | } 269 | 270 | async function handleEvent(account: string, args: string[]) { 271 | const calendarId = args[0]; 272 | const eventId = args[1]; 273 | if (!calendarId || !eventId) error("Usage: event "); 274 | 275 | const event = await service.getEvent(account, calendarId, eventId); 276 | 277 | console.log(`ID: ${event.id}`); 278 | console.log(`Summary: ${event.summary || "(no title)"}`); 279 | console.log(`Start: ${event.start?.dateTime || event.start?.date || ""}`); 280 | console.log(`End: ${event.end?.dateTime || event.end?.date || ""}`); 281 | if (event.location) console.log(`Location: ${event.location}`); 282 | if (event.description) console.log(`Description: ${event.description}`); 283 | if (event.attendees && event.attendees.length > 0) { 284 | console.log(`Attendees: ${event.attendees.map((a) => a.email).join(", ")}`); 285 | } 286 | console.log(`Status: ${event.status}`); 287 | console.log(`Link: ${event.htmlLink}`); 288 | } 289 | 290 | async function handleCreate(account: string, args: string[]) { 291 | const { values, positionals } = parseArgs({ 292 | args, 293 | options: { 294 | summary: { type: "string" }, 295 | description: { type: "string" }, 296 | location: { type: "string" }, 297 | start: { type: "string" }, 298 | end: { type: "string" }, 299 | attendees: { type: "string" }, 300 | "all-day": { type: "boolean" }, 301 | }, 302 | allowPositionals: true, 303 | }); 304 | 305 | const calendarId = positionals[0]; 306 | if (!calendarId) error("Usage: create --summary --start
--end
"); 307 | if (!values.summary || !values.start || !values.end) { 308 | error("Required: --summary, --start, --end"); 309 | } 310 | 311 | const event = await service.createEvent(account, calendarId, { 312 | summary: values.summary, 313 | description: values.description, 314 | location: values.location, 315 | start: values.start, 316 | end: values.end, 317 | attendees: values.attendees?.split(","), 318 | allDay: values["all-day"], 319 | }); 320 | 321 | console.log(`Created: ${event.id}`); 322 | console.log(`Link: ${event.htmlLink}`); 323 | } 324 | 325 | async function handleUpdate(account: string, args: string[]) { 326 | const { values, positionals } = parseArgs({ 327 | args, 328 | options: { 329 | summary: { type: "string" }, 330 | description: { type: "string" }, 331 | location: { type: "string" }, 332 | start: { type: "string" }, 333 | end: { type: "string" }, 334 | attendees: { type: "string" }, 335 | "all-day": { type: "boolean" }, 336 | }, 337 | allowPositionals: true, 338 | }); 339 | 340 | const calendarId = positionals[0]; 341 | const eventId = positionals[1]; 342 | if (!calendarId || !eventId) error("Usage: update [options]"); 343 | 344 | const event = await service.updateEvent(account, calendarId, eventId, { 345 | summary: values.summary, 346 | description: values.description, 347 | location: values.location, 348 | start: values.start, 349 | end: values.end, 350 | attendees: values.attendees?.split(","), 351 | allDay: values["all-day"], 352 | }); 353 | 354 | console.log(`Updated: ${event.id}`); 355 | } 356 | 357 | async function handleDelete(account: string, args: string[]) { 358 | const calendarId = args[0]; 359 | const eventId = args[1]; 360 | if (!calendarId || !eventId) error("Usage: delete "); 361 | 362 | await service.deleteEvent(account, calendarId, eventId); 363 | console.log("Deleted"); 364 | } 365 | 366 | async function handleFreeBusy(account: string, args: string[]) { 367 | const { values, positionals } = parseArgs({ 368 | args, 369 | options: { 370 | from: { type: "string" }, 371 | to: { type: "string" }, 372 | }, 373 | allowPositionals: true, 374 | }); 375 | 376 | const calendarIds = positionals[0]; 377 | if (!calendarIds || !values.from || !values.to) { 378 | error("Usage: freebusy --from
--to
"); 379 | } 380 | 381 | const result = await service.getFreeBusy(account, calendarIds.split(","), values.from, values.to); 382 | 383 | for (const [calId, busy] of result) { 384 | console.log(`${calId}:`); 385 | if (busy.length === 0) { 386 | console.log(" (free)"); 387 | } else { 388 | for (const b of busy) { 389 | console.log(` ${b.start} - ${b.end}`); 390 | } 391 | } 392 | } 393 | } 394 | 395 | main(); 396 | --------------------------------------------------------------------------------