From 05d1fa41ba01948dd6d501220df0f4cca20812cd Mon Sep 17 00:00:00 2001 From: OpenCode Test Date: Thu, 1 Jan 2026 02:33:10 -0800 Subject: [PATCH] Add hooks and refactor skills to use resources pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of plugin-structure refactor: - Add hooks/hooks.json for SessionStart automation - Refactor gmail skill: - Extract inline scripts to scripts/check_unread.py, check_urgent.py, search.py - Add references/query-patterns.md for query documentation - Simplify SKILL.md to reference scripts instead of inline code - Add gcal/scripts/agenda.py for direct calendar access - Make all scripts executable This follows the "Skill with Bundled Resources" pattern from developing-claude-code-plugins best practices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- hooks/hooks.json | 14 ++ skills/gcal/scripts/agenda.py | 104 ++++++++++++ skills/gmail/SKILL.md | 184 ++++++++-------------- skills/gmail/references/query-patterns.md | 57 +++++++ skills/gmail/scripts/check_unread.py | 48 ++++++ skills/gmail/scripts/check_urgent.py | 38 +++++ skills/gmail/scripts/search.py | 47 ++++++ 7 files changed, 370 insertions(+), 122 deletions(-) create mode 100644 hooks/hooks.json create mode 100755 skills/gcal/scripts/agenda.py create mode 100644 skills/gmail/references/query-patterns.md create mode 100755 skills/gmail/scripts/check_unread.py create mode 100755 skills/gmail/scripts/check_urgent.py create mode 100755 skills/gmail/scripts/search.py diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..f672c13 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo 'SessionStart:Callback hook success: Success'" + } + ] + } + ] + } +} diff --git a/skills/gcal/scripts/agenda.py b/skills/gcal/scripts/agenda.py new file mode 100755 index 0000000..d0b3d6b --- /dev/null +++ b/skills/gcal/scripts/agenda.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Get calendar agenda for a time range.""" +import os +import sys +from datetime import datetime, timedelta + +# Set credentials path +os.environ.setdefault('GMAIL_CREDENTIALS_PATH', os.path.expanduser('~/.gmail-mcp/credentials.json')) + +from gmail_mcp.utils.GCP.gmail_auth import get_calendar_service + +def format_event(event): + """Format a single event for display.""" + start = event['start'].get('dateTime', event['start'].get('date')) + end = event['end'].get('dateTime', event['end'].get('date')) + + # Parse datetime + if 'T' in start: + start_dt = datetime.fromisoformat(start.replace('Z', '+00:00')) + end_dt = datetime.fromisoformat(end.replace('Z', '+00:00')) + time_str = start_dt.strftime('%I:%M %p').lstrip('0') + duration = end_dt - start_dt + if duration.seconds >= 3600: + dur_str = f"{duration.seconds // 3600}h" + else: + dur_str = f"{duration.seconds // 60}m" + else: + time_str = "All day" + dur_str = "" + + summary = event.get('summary', '(No title)') + location = event.get('location', '') + + line = f" {time_str:>10} {summary}" + if dur_str: + line += f" ({dur_str})" + if location: + line += f"\n 📍 {location}" + + return line + +def main(): + mode = sys.argv[1] if len(sys.argv) > 1 else 'today' + + service = get_calendar_service() + now = datetime.utcnow() + + if mode == 'today': + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) + title = f"Today — {start.strftime('%A, %b %d')}" + elif mode == 'tomorrow': + start = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) + title = f"Tomorrow — {start.strftime('%A, %b %d')}" + elif mode == 'week': + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=7) + title = f"This Week — {start.strftime('%b %d')}-{end.strftime('%d')}" + else: + start = now + end = now + timedelta(days=7) + title = "Upcoming" + + events_result = service.events().list( + calendarId='primary', + timeMin=start.isoformat() + 'Z', + timeMax=end.isoformat() + 'Z', + singleEvents=True, + orderBy='startTime', + maxResults=50 + ).execute() + + events = events_result.get('items', []) + + print(f"📅 {title}\n") + + if not events: + print("No events scheduled.") + return + + if mode == 'week': + # Group by day + by_day = {} + for event in events: + start_str = event['start'].get('dateTime', event['start'].get('date')) + day = start_str[:10] + if day not in by_day: + by_day[day] = [] + by_day[day].append(event) + + for day, day_events in sorted(by_day.items()): + day_dt = datetime.fromisoformat(day) + print(f"━━━ {day_dt.strftime('%A, %b %d')} ━━━") + for event in day_events: + print(format_event(event)) + print() + else: + for event in events: + print(format_event(event)) + print("\nNo more events" + (" today." if mode == 'today' else ".")) + +if __name__ == '__main__': + main() diff --git a/skills/gmail/SKILL.md b/skills/gmail/SKILL.md index 936140b..dccd3e0 100644 --- a/skills/gmail/SKILL.md +++ b/skills/gmail/SKILL.md @@ -1,147 +1,87 @@ --- name: gmail -description: Gmail read access via direct Python API - search, check unread, detect urgent emails +description: Gmail read access via Python API - search, check unread, detect urgent emails. Use when user asks about email, inbox, or messages. allowed-tools: - Bash --- # Gmail Skill -Access Gmail via direct Python API calls. Uses OAuth credentials at `~/.gmail-mcp/`. +Access Gmail via Python API calls. Uses OAuth credentials at `~/.gmail-mcp/`. -## Delegated Operations (Recommended) +## Quick Commands -Use the tiered delegation helper for cost-efficient operations. Uses Claude CLI with your subscription (no API key needed): +Run scripts using the gmail venv: + +```bash +GMAIL_PY=~/.claude/mcp/gmail/venv/bin/python +SCRIPTS=~/.claude/skills/gmail/scripts + +# Check unread (last 7 days, grouped by sender) +$GMAIL_PY $SCRIPTS/check_unread.py 7 + +# Check urgent emails +$GMAIL_PY $SCRIPTS/check_urgent.py + +# Search with custom query +$GMAIL_PY $SCRIPTS/search.py "from:github.com" 10 +``` + +## Script Reference + +| Script | Purpose | Args | +|--------|---------|------| +| `check_unread.py` | List unread, grouped by sender | `[days] [max]` | +| `check_urgent.py` | Find urgent/important emails | none | +| `search.py` | Custom query search | ` [max]` | + +## Request Routing + +| User Request | Script | Tier | +|--------------|--------|------| +| "Check my email" | `check_unread.py` | Haiku | +| "How many unread?" | `check_unread.py` | Haiku | +| "Any urgent emails?" | `check_urgent.py` | Haiku | +| "Search for X" | `search.py "X"` | Haiku | +| "Summarize my inbox" | Run script + analyze | Sonnet | +| "What should I prioritize?" | Run script + reason | Opus | + +## Query Patterns + +For custom searches, see [references/query-patterns.md](references/query-patterns.md). + +Common queries: +- `is:unread newer_than:7d` - Unread last week +- `from:github.com` - GitHub notifications +- `has:attachment larger:5M` - Large attachments +- `subject:urgent is:unread` - Urgent unread + +## Delegation Helper (Advanced) + +For LLM-assisted operations (summarization, triage): ```bash GMAIL_PY=~/.claude/mcp/gmail/venv/bin/python HELPER=~/.claude/mcp/delegation/gmail_delegate.py -# Haiku tier - list unread (no LLM call, just fetches) -$GMAIL_PY $HELPER check-unread --days 7 - -# Sonnet tier - summarize emails (spawns claude --model sonnet) +# Summarize emails (spawns claude --model sonnet) $GMAIL_PY $HELPER summarize --query "from:github.com" -# Sonnet tier - triage urgent (spawns claude --model sonnet) +# Triage urgent (spawns claude --model sonnet) $GMAIL_PY $HELPER urgent ``` -### When to Use Each Tier - -| Request Type | Command | Model | -|--------------|---------|-------| -| "Check my email" | `check-unread` | Haiku | -| "How many unread?" | `check-unread` | Haiku | -| "Summarize X" | `summarize --query "X"` | Sonnet | -| "What's urgent?" | `urgent` | Sonnet | -| "What should I prioritize?" | (PA direct) | Opus | - -## Usage - -For any Gmail request, use Bash to run the Python helper: - -```bash -GMAIL_CREDENTIALS_PATH=~/.gmail-mcp/credentials.json ~/.claude/mcp/gmail/venv/bin/python << 'EOF' -from gmail_mcp.utils.GCP.gmail_auth import get_gmail_service -from collections import defaultdict - -service = get_gmail_service() - -# Your query here -results = service.users().messages().list(userId='me', q='is:unread newer_than:3d', maxResults=25).execute() -messages = results.get('messages', []) - -for msg in messages: - detail = service.users().messages().get(userId='me', id=msg['id'], format='metadata', metadataHeaders=['From', 'Subject', 'Date']).execute() - headers = {h['name']: h['value'] for h in detail['payload']['headers']} - print(f"From: {headers.get('From', 'Unknown')}") - print(f"Subject: {headers.get('Subject', '(no subject)')}") - print(f"Date: {headers.get('Date', 'Unknown')}") - print("---") -EOF -``` - -## Query Patterns - -| Request | Gmail Query | -|---------|-------------| -| Unread | `is:unread` | -| Last N days | `newer_than:Nd` | -| From sender | `from:email@example.com` | -| With attachments | `has:attachment` | -| Important | `is:important` | -| Urgent keywords | `subject:(urgent OR asap OR "action required")` | - -## Common Tasks - -### Check unread (grouped by sender) -```bash -GMAIL_CREDENTIALS_PATH=~/.gmail-mcp/credentials.json ~/.claude/mcp/gmail/venv/bin/python << 'EOF' -from gmail_mcp.utils.GCP.gmail_auth import get_gmail_service -from collections import defaultdict -service = get_gmail_service() -results = service.users().messages().list(userId='me', q='is:unread newer_than:7d', maxResults=25).execute() -by_sender = defaultdict(list) -for msg in results.get('messages', []): - detail = service.users().messages().get(userId='me', id=msg['id'], format='metadata', metadataHeaders=['From', 'Subject']).execute() - headers = {h['name']: h['value'] for h in detail['payload']['headers']} - sender = headers.get('From', 'Unknown').split('<')[0].strip().strip('"') - by_sender[sender].append(headers.get('Subject', '(no subject)')[:50]) -for sender, subjects in sorted(by_sender.items(), key=lambda x: -len(x[1])): - print(f"* {sender} ({len(subjects)})") - for s in subjects[:2]: print(f" - {s}") - if len(subjects) > 2: print(f" - ...+{len(subjects)-2} more") -EOF -``` - -### Check urgent -```bash -GMAIL_CREDENTIALS_PATH=~/.gmail-mcp/credentials.json ~/.claude/mcp/gmail/venv/bin/python << 'EOF' -from gmail_mcp.utils.GCP.gmail_auth import get_gmail_service -service = get_gmail_service() -results = service.users().messages().list(userId='me', q='is:unread newer_than:3d (subject:urgent OR subject:asap OR subject:"action required" OR is:important)', maxResults=15).execute() -for msg in results.get('messages', []): - detail = service.users().messages().get(userId='me', id=msg['id'], format='metadata', metadataHeaders=['From', 'Subject', 'Date']).execute() - headers = {h['name']: h['value'] for h in detail['payload']['headers']} - print(f"From: {headers.get('From', 'Unknown')}") - print(f"Subject: {headers.get('Subject', '(no subject)')}") - print(f"Date: {headers.get('Date', 'Unknown')}") - print("---") -EOF -``` - ## Model Selection -Gmail operations use tiered delegation per `model-policy.json`: - -| Model | Use For | Examples | -|-------|---------|----------| -| **Haiku** | Fetch, count, list, simple search | "How many unread?", "List from X" | -| **Sonnet** | Summarize, categorize, extract | "Summarize this email", "Group by topic" | -| **Opus** | Prioritize, analyze, cross-reference | "What should I handle first?" | - -### Delegation Pattern - -When invoking gmail operations, the PA should: - -1. **Classify the request** — Is it fetch-only, summarization, or analysis? -2. **Delegate appropriately**: - - Haiku: API calls + simple formatting - - Sonnet: API calls + content understanding - - Opus: Only for strategic reasoning -3. **Escalate if needed** — If lower tier can't handle it, escalate - -### Implementation Note - -Until Task tool delegation is available, the PA executes gmail operations directly -but should mentally "account" for which tier the work belongs to. This policy -enables future cost optimization when subagent spawning is implemented. +| Model | Use For | +|-------|---------| +| **Haiku** | Fetch, count, list, simple search | +| **Sonnet** | Summarize, categorize, extract | +| **Opus** | Prioritize, analyze, cross-reference | ## Policy -- Read-only operations only -- Summarize results (don't dump raw content) -- Report metadata, not full body unless asked -- Start with lowest capable model tier -- Escalate only when task complexity requires +- **Read-only** operations only +- **Summarize** results (don't dump raw content) +- Report **metadata**, not full body unless asked +- Start with **lowest capable** model tier diff --git a/skills/gmail/references/query-patterns.md b/skills/gmail/references/query-patterns.md new file mode 100644 index 0000000..939c53d --- /dev/null +++ b/skills/gmail/references/query-patterns.md @@ -0,0 +1,57 @@ +# Gmail Query Patterns + +## Basic Queries + +| Request | Gmail Query | +|---------|-------------| +| Unread | `is:unread` | +| Last N days | `newer_than:Nd` | +| From sender | `from:email@example.com` | +| To recipient | `to:email@example.com` | +| With attachments | `has:attachment` | +| Important | `is:important` | +| Starred | `is:starred` | +| In inbox | `in:inbox` | + +## Urgent/Priority + +| Pattern | Query | +|---------|-------| +| Urgent keywords | `subject:(urgent OR asap OR "action required")` | +| Important + unread | `is:important is:unread` | +| Time-sensitive | `subject:(deadline OR "due date" OR "end of day")` | + +## Sender Patterns + +| Pattern | Query | +|---------|-------| +| GitHub notifications | `from:github.com` | +| Google alerts | `from:googlealerts-noreply@google.com` | +| Calendar invites | `from:calendar-notification@google.com` | +| Newsletters | `category:promotions` | + +## Combining Queries + +Queries can be combined: + +``` +is:unread newer_than:7d from:github.com +is:unread (subject:urgent OR is:important) newer_than:3d +has:attachment from:client@example.com newer_than:30d +``` + +## Date Queries + +| Pattern | Query | +|---------|-------| +| Last 24 hours | `newer_than:1d` | +| Last week | `newer_than:7d` | +| Last month | `newer_than:30d` | +| Specific date | `after:2024/01/01 before:2024/01/31` | + +## Size Queries + +| Pattern | Query | +|---------|-------| +| Large attachments | `larger:10M` | +| Small emails | `smaller:100K` | diff --git a/skills/gmail/scripts/check_unread.py b/skills/gmail/scripts/check_unread.py new file mode 100755 index 0000000..a3cf5c9 --- /dev/null +++ b/skills/gmail/scripts/check_unread.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Check unread emails, grouped by sender.""" +import os +import sys +from collections import defaultdict + +# Set credentials path +os.environ.setdefault('GMAIL_CREDENTIALS_PATH', os.path.expanduser('~/.gmail-mcp/credentials.json')) + +from gmail_mcp.utils.GCP.gmail_auth import get_gmail_service + +def main(): + days = int(sys.argv[1]) if len(sys.argv) > 1 else 7 + max_results = int(sys.argv[2]) if len(sys.argv) > 2 else 25 + + service = get_gmail_service() + results = service.users().messages().list( + userId='me', + q=f'is:unread newer_than:{days}d', + maxResults=max_results + ).execute() + + by_sender = defaultdict(list) + for msg in results.get('messages', []): + detail = service.users().messages().get( + userId='me', + id=msg['id'], + format='metadata', + metadataHeaders=['From', 'Subject'] + ).execute() + headers = {h['name']: h['value'] for h in detail['payload']['headers']} + sender = headers.get('From', 'Unknown').split('<')[0].strip().strip('"') + by_sender[sender].append(headers.get('Subject', '(no subject)')[:50]) + + if not by_sender: + print("No unread emails in the last", days, "days") + return + + print(f"Unread emails (last {days} days):\n") + for sender, subjects in sorted(by_sender.items(), key=lambda x: -len(x[1])): + print(f"* {sender} ({len(subjects)})") + for s in subjects[:2]: + print(f" - {s}") + if len(subjects) > 2: + print(f" - ...+{len(subjects)-2} more") + +if __name__ == '__main__': + main() diff --git a/skills/gmail/scripts/check_urgent.py b/skills/gmail/scripts/check_urgent.py new file mode 100755 index 0000000..d2242d1 --- /dev/null +++ b/skills/gmail/scripts/check_urgent.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Check for urgent unread emails.""" +import os + +# Set credentials path +os.environ.setdefault('GMAIL_CREDENTIALS_PATH', os.path.expanduser('~/.gmail-mcp/credentials.json')) + +from gmail_mcp.utils.GCP.gmail_auth import get_gmail_service + +def main(): + service = get_gmail_service() + results = service.users().messages().list( + userId='me', + q='is:unread newer_than:3d (subject:urgent OR subject:asap OR subject:"action required" OR is:important)', + maxResults=15 + ).execute() + + messages = results.get('messages', []) + if not messages: + print("No urgent emails found") + return + + print(f"Found {len(messages)} urgent email(s):\n") + for msg in messages: + detail = service.users().messages().get( + userId='me', + id=msg['id'], + format='metadata', + metadataHeaders=['From', 'Subject', 'Date'] + ).execute() + headers = {h['name']: h['value'] for h in detail['payload']['headers']} + print(f"From: {headers.get('From', 'Unknown')}") + print(f"Subject: {headers.get('Subject', '(no subject)')}") + print(f"Date: {headers.get('Date', 'Unknown')}") + print("---") + +if __name__ == '__main__': + main() diff --git a/skills/gmail/scripts/search.py b/skills/gmail/scripts/search.py new file mode 100755 index 0000000..6e1c7f2 --- /dev/null +++ b/skills/gmail/scripts/search.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Search emails with a custom query.""" +import os +import sys + +# Set credentials path +os.environ.setdefault('GMAIL_CREDENTIALS_PATH', os.path.expanduser('~/.gmail-mcp/credentials.json')) + +from gmail_mcp.utils.GCP.gmail_auth import get_gmail_service + +def main(): + if len(sys.argv) < 2: + print("Usage: search.py [max_results]") + print("Example: search.py 'from:github.com' 10") + sys.exit(1) + + query = sys.argv[1] + max_results = int(sys.argv[2]) if len(sys.argv) > 2 else 20 + + service = get_gmail_service() + results = service.users().messages().list( + userId='me', + q=query, + maxResults=max_results + ).execute() + + messages = results.get('messages', []) + if not messages: + print(f"No emails found for query: {query}") + return + + print(f"Found {len(messages)} email(s) for query: {query}\n") + for msg in messages: + detail = service.users().messages().get( + userId='me', + id=msg['id'], + format='metadata', + metadataHeaders=['From', 'Subject', 'Date'] + ).execute() + headers = {h['name']: h['value'] for h in detail['payload']['headers']} + print(f"From: {headers.get('From', 'Unknown')}") + print(f"Subject: {headers.get('Subject', '(no subject)')}") + print(f"Date: {headers.get('Date', 'Unknown')}") + print("---") + +if __name__ == '__main__': + main()