Add hooks and refactor skills to use resources pattern
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 <noreply@anthropic.com>
This commit is contained in:
104
skills/gcal/scripts/agenda.py
Executable file
104
skills/gcal/scripts/agenda.py
Executable file
@@ -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()
|
||||
@@ -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 | `<query> [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
|
||||
|
||||
57
skills/gmail/references/query-patterns.md
Normal file
57
skills/gmail/references/query-patterns.md
Normal file
@@ -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` |
|
||||
48
skills/gmail/scripts/check_unread.py
Executable file
48
skills/gmail/scripts/check_unread.py
Executable file
@@ -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()
|
||||
38
skills/gmail/scripts/check_urgent.py
Executable file
38
skills/gmail/scripts/check_urgent.py
Executable file
@@ -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()
|
||||
47
skills/gmail/scripts/search.py
Executable file
47
skills/gmail/scripts/search.py
Executable file
@@ -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 <query> [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()
|
||||
Reference in New Issue
Block a user