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:
OpenCode Test
2026-01-01 02:33:10 -08:00
parent 8a52b3ee59
commit 05d1fa41ba
7 changed files with 370 additions and 122 deletions

14
hooks/hooks.json Normal file
View File

@@ -0,0 +1,14 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo 'SessionStart:Callback hook success: Success'"
}
]
}
]
}
}

104
skills/gcal/scripts/agenda.py Executable file
View 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()

View File

@@ -1,147 +1,87 @@
--- ---
name: gmail 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: allowed-tools:
- Bash - Bash
--- ---
# Gmail Skill # 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 ```bash
GMAIL_PY=~/.claude/mcp/gmail/venv/bin/python GMAIL_PY=~/.claude/mcp/gmail/venv/bin/python
HELPER=~/.claude/mcp/delegation/gmail_delegate.py HELPER=~/.claude/mcp/delegation/gmail_delegate.py
# Haiku tier - list unread (no LLM call, just fetches) # Summarize emails (spawns claude --model sonnet)
$GMAIL_PY $HELPER check-unread --days 7
# Sonnet tier - summarize emails (spawns claude --model sonnet)
$GMAIL_PY $HELPER summarize --query "from:github.com" $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 $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 ## Model Selection
Gmail operations use tiered delegation per `model-policy.json`: | Model | Use For |
|-------|---------|
| Model | Use For | Examples | | **Haiku** | Fetch, count, list, simple search |
|-------|---------|----------| | **Sonnet** | Summarize, categorize, extract |
| **Haiku** | Fetch, count, list, simple search | "How many unread?", "List from X" | | **Opus** | Prioritize, analyze, cross-reference |
| **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.
## Policy ## Policy
- Read-only operations only - **Read-only** operations only
- Summarize results (don't dump raw content) - **Summarize** results (don't dump raw content)
- Report metadata, not full body unless asked - Report **metadata**, not full body unless asked
- Start with lowest capable model tier - Start with **lowest capable** model tier
- Escalate only when task complexity requires

View 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` |

View 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()

View 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
View 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()