Improve morning report collectors and add section toggling

- Add is_section_enabled() to support per-section enable/disable in config
- Update Python path from 3.13 to 3.14 for gmail venv
- Disable tasks section by default (enabled: false in config)
- Apply code formatting improvements (black/ruff style)

🤖 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-04 23:44:24 -08:00
parent 7ca8caeecb
commit 45b7e4bcf7
5 changed files with 146 additions and 80 deletions

View File

@@ -25,6 +25,7 @@
"show_tomorrow": true "show_tomorrow": true
}, },
"tasks": { "tasks": {
"enabled": false,
"max_display": 5, "max_display": 5,
"show_due_dates": true "show_due_dates": true
}, },

View File

@@ -9,11 +9,13 @@ from pathlib import Path
def fetch_events(mode: str = "today") -> list: def fetch_events(mode: str = "today") -> list:
"""Fetch calendar events directly using gmail_mcp library.""" """Fetch calendar events directly using gmail_mcp library."""
os.environ.setdefault('GMAIL_CREDENTIALS_PATH', os.path.expanduser('~/.gmail-mcp/credentials.json')) os.environ.setdefault(
"GMAIL_CREDENTIALS_PATH", os.path.expanduser("~/.gmail-mcp/credentials.json")
)
try: try:
# Add gmail venv to path # Add gmail venv to path
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.13/site-packages" venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.14/site-packages"
if str(venv_site) not in sys.path: if str(venv_site) not in sys.path:
sys.path.insert(0, str(venv_site)) sys.path.insert(0, str(venv_site))
@@ -22,26 +24,32 @@ def fetch_events(mode: str = "today") -> list:
service = get_calendar_service() service = get_calendar_service()
now = datetime.utcnow() now = datetime.utcnow()
if mode == 'today': if mode == "today":
start = now.replace(hour=0, minute=0, second=0, microsecond=0) start = now.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1) end = start + timedelta(days=1)
elif mode == 'tomorrow': elif mode == "tomorrow":
start = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) start = (now + timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
end = start + timedelta(days=1) end = start + timedelta(days=1)
else: else:
start = now start = now
end = now + timedelta(days=7) end = now + timedelta(days=7)
events_result = service.events().list( events_result = (
calendarId='primary', service.events()
timeMin=start.isoformat() + 'Z', .list(
timeMax=end.isoformat() + 'Z', calendarId="primary",
timeMin=start.isoformat() + "Z",
timeMax=end.isoformat() + "Z",
singleEvents=True, singleEvents=True,
orderBy='startTime', orderBy="startTime",
maxResults=20 maxResults=20,
).execute() )
.execute()
)
return events_result.get('items', []) return events_result.get("items", [])
except Exception as e: except Exception as e:
return [{"error": str(e)}] return [{"error": str(e)}]
@@ -62,7 +70,9 @@ def format_events(today_events: list, tomorrow_events: list = None) -> str:
if "dateTime" in start: if "dateTime" in start:
# Timed event # Timed event
dt = datetime.fromisoformat(start["dateTime"].replace("Z", "+00:00")) dt = datetime.fromisoformat(
start["dateTime"].replace("Z", "+00:00")
)
time_str = dt.strftime("%I:%M %p").lstrip("0") time_str = dt.strftime("%I:%M %p").lstrip("0")
elif "date" in start: elif "date" in start:
time_str = "All day" time_str = "All day"
@@ -73,13 +83,19 @@ def format_events(today_events: list, tomorrow_events: list = None) -> str:
# Calculate duration if end time available # Calculate duration if end time available
end = event.get("end", {}) end = event.get("end", {})
if "dateTime" in start and "dateTime" in end: if "dateTime" in start and "dateTime" in end:
start_dt = datetime.fromisoformat(start["dateTime"].replace("Z", "+00:00")) start_dt = datetime.fromisoformat(
end_dt = datetime.fromisoformat(end["dateTime"].replace("Z", "+00:00")) start["dateTime"].replace("Z", "+00:00")
)
end_dt = datetime.fromisoformat(
end["dateTime"].replace("Z", "+00:00")
)
mins = int((end_dt - start_dt).total_seconds() / 60) mins = int((end_dt - start_dt).total_seconds() / 60)
if mins >= 60: if mins >= 60:
hours = mins // 60 hours = mins // 60
remaining = mins % 60 remaining = mins % 60
duration = f" ({hours}h{remaining}m)" if remaining else f" ({hours}h)" duration = (
f" ({hours}h{remaining}m)" if remaining else f" ({hours}h)"
)
else: else:
duration = f" ({mins}m)" duration = f" ({mins}m)"
@@ -92,17 +108,23 @@ def format_events(today_events: list, tomorrow_events: list = None) -> str:
# Tomorrow preview # Tomorrow preview
if tomorrow_events is not None: if tomorrow_events is not None:
if tomorrow_events and (len(tomorrow_events) == 0 or "error" not in tomorrow_events[0]): if tomorrow_events and (
len(tomorrow_events) == 0 or "error" not in tomorrow_events[0]
):
count = len(tomorrow_events) count = len(tomorrow_events)
if count > 0: if count > 0:
first = tomorrow_events[0] first = tomorrow_events[0]
start = first.get("start", {}) start = first.get("start", {})
if "dateTime" in start: if "dateTime" in start:
dt = datetime.fromisoformat(start["dateTime"].replace("Z", "+00:00")) dt = datetime.fromisoformat(
start["dateTime"].replace("Z", "+00:00")
)
first_time = dt.strftime("%I:%M %p").lstrip("0") first_time = dt.strftime("%I:%M %p").lstrip("0")
else: else:
first_time = "All day" first_time = "All day"
lines.append(f"Tomorrow: {count} event{'s' if count > 1 else ''}, first at {first_time}") lines.append(
f"Tomorrow: {count} event{'s' if count > 1 else ''}, first at {first_time}"
)
else: else:
lines.append("Tomorrow: No events") lines.append("Tomorrow: No events")
@@ -126,7 +148,7 @@ def collect(config: dict) -> dict:
"icon": "📅", "icon": "📅",
"content": formatted, "content": formatted,
"raw": {"today": today_events, "tomorrow": tomorrow_events}, "raw": {"today": today_events, "tomorrow": tomorrow_events},
"error": today_events[0].get("error") if has_error else None "error": today_events[0].get("error") if has_error else None,
} }

View File

@@ -11,37 +11,49 @@ from pathlib import Path
def fetch_unread_emails(days: int = 7, max_results: int = 15) -> list: def fetch_unread_emails(days: int = 7, max_results: int = 15) -> list:
"""Fetch unread emails directly using gmail_mcp library.""" """Fetch unread emails directly using gmail_mcp library."""
# Set credentials path # Set credentials path
os.environ.setdefault('GMAIL_CREDENTIALS_PATH', os.path.expanduser('~/.gmail-mcp/credentials.json')) os.environ.setdefault(
"GMAIL_CREDENTIALS_PATH", os.path.expanduser("~/.gmail-mcp/credentials.json")
)
try: try:
# Add gmail venv to path # Add gmail venv to path
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.13/site-packages" venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.14/site-packages"
if str(venv_site) not in sys.path: if str(venv_site) not in sys.path:
sys.path.insert(0, str(venv_site)) sys.path.insert(0, str(venv_site))
from gmail_mcp.utils.GCP.gmail_auth import get_gmail_service from gmail_mcp.utils.GCP.gmail_auth import get_gmail_service
service = get_gmail_service() service = get_gmail_service()
results = service.users().messages().list( results = (
userId='me', service.users()
q=f'is:unread newer_than:{days}d', .messages()
maxResults=max_results .list(
).execute() userId="me", q=f"is:unread newer_than:{days}d", maxResults=max_results
)
.execute()
)
emails = [] emails = []
for msg in results.get('messages', []): for msg in results.get("messages", []):
detail = service.users().messages().get( detail = (
userId='me', service.users()
id=msg['id'], .messages()
format='metadata', .get(
metadataHeaders=['From', 'Subject'] userId="me",
).execute() id=msg["id"],
headers = {h['name']: h['value'] for h in detail['payload']['headers']} format="metadata",
emails.append({ metadataHeaders=["From", "Subject"],
'from': headers.get('From', 'Unknown'), )
'subject': headers.get('Subject', '(no subject)'), .execute()
'id': msg['id'] )
}) headers = {h["name"]: h["value"] for h in detail["payload"]["headers"]}
emails.append(
{
"from": headers.get("From", "Unknown"),
"subject": headers.get("Subject", "(no subject)"),
"id": msg["id"],
}
)
return emails return emails
@@ -79,10 +91,17 @@ Output the formatted email section, nothing else."""
try: try:
result = subprocess.run( result = subprocess.run(
["/home/will/.local/bin/claude", "--print", "--model", "sonnet", "-p", prompt], [
"/home/will/.local/bin/claude",
"--print",
"--model",
"sonnet",
"-p",
prompt,
],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=60 timeout=60,
) )
if result.returncode == 0 and result.stdout.strip(): if result.returncode == 0 and result.stdout.strip():
@@ -131,7 +150,7 @@ def collect(config: dict) -> dict:
"content": formatted, "content": formatted,
"raw": emails if not has_error else None, "raw": emails if not has_error else None,
"count": len(emails) if not has_error else 0, "count": len(emails) if not has_error else 0,
"error": emails[0].get("error") if has_error else None "error": emails[0].get("error") if has_error else None,
} }

View File

@@ -8,7 +8,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
# Add gmail venv to path for Google API libraries # Add gmail venv to path for Google API libraries
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.13/site-packages" venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.14/site-packages"
if str(venv_site) not in sys.path: if str(venv_site) not in sys.path:
sys.path.insert(0, str(venv_site)) sys.path.insert(0, str(venv_site))
@@ -18,6 +18,7 @@ try:
from google_auth_oauthlib.flow import InstalledAppFlow from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request from google.auth.transport.requests import Request
from googleapiclient.discovery import build from googleapiclient.discovery import build
GOOGLE_API_AVAILABLE = True GOOGLE_API_AVAILABLE = True
except ImportError: except ImportError:
GOOGLE_API_AVAILABLE = False GOOGLE_API_AVAILABLE = False
@@ -57,7 +58,11 @@ def fetch_tasks(max_results: int = 10) -> list:
try: try:
creds = get_credentials() creds = get_credentials()
if not creds: if not creds:
return [{"error": "Tasks API not authenticated - run: ~/.claude/mcp/gmail/venv/bin/python ~/.claude/skills/morning-report/scripts/collectors/gtasks.py --auth"}] return [
{
"error": "Tasks API not authenticated - run: ~/.claude/mcp/gmail/venv/bin/python ~/.claude/skills/morning-report/scripts/collectors/gtasks.py --auth"
}
]
service = build("tasks", "v1", credentials=creds) service = build("tasks", "v1", credentials=creds)
@@ -69,12 +74,16 @@ def fetch_tasks(max_results: int = 10) -> list:
tasklist_id = tasklists["items"][0]["id"] tasklist_id = tasklists["items"][0]["id"]
# Get tasks # Get tasks
results = service.tasks().list( results = (
service.tasks()
.list(
tasklist=tasklist_id, tasklist=tasklist_id,
maxResults=max_results, maxResults=max_results,
showCompleted=False, showCompleted=False,
showHidden=False showHidden=False,
).execute() )
.execute()
)
tasks = results.get("items", []) tasks = results.get("items", [])
return tasks return tasks
@@ -150,7 +159,7 @@ def collect(config: dict) -> dict:
"content": formatted, "content": formatted,
"raw": tasks if not has_error else None, "raw": tasks if not has_error else None,
"count": len(tasks) if not has_error else 0, "count": len(tasks) if not has_error else 0,
"error": tasks[0].get("error") if has_error else None "error": tasks[0].get("error") if has_error else None,
} }

View File

@@ -18,6 +18,7 @@ from collectors import weather, stocks, infra, news
# These may fail if gmail venv not activated # These may fail if gmail venv not activated
try: try:
from collectors import gmail, gcal, gtasks from collectors import gmail, gcal, gtasks
GOOGLE_COLLECTORS = True GOOGLE_COLLECTORS = True
except ImportError: except ImportError:
GOOGLE_COLLECTORS = False GOOGLE_COLLECTORS = False
@@ -29,10 +30,7 @@ LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s", format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[ handlers=[logging.FileHandler(LOG_PATH), logging.StreamHandler()],
logging.FileHandler(LOG_PATH),
logging.StreamHandler()
]
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -58,10 +56,17 @@ def collect_section(name: str, collector_func, config: dict) -> dict:
"section": name, "section": name,
"icon": "", "icon": "",
"content": f"⚠️ {name} unavailable: {e}", "content": f"⚠️ {name} unavailable: {e}",
"error": str(e) "error": str(e),
} }
def is_section_enabled(name: str, config: dict) -> bool:
"""Check if a section is enabled in config."""
section_key = name.lower()
section_config = config.get(section_key, {})
return section_config.get("enabled", True)
def collect_all(config: dict) -> list: def collect_all(config: dict) -> list:
"""Collect all sections in parallel.""" """Collect all sections in parallel."""
collectors = [ collectors = [
@@ -72,11 +77,12 @@ def collect_all(config: dict) -> list:
] ]
if GOOGLE_COLLECTORS: if GOOGLE_COLLECTORS:
collectors.extend([ if is_section_enabled("email", config):
("Email", gmail.collect), collectors.append(("Email", gmail.collect))
("Calendar", gcal.collect), if is_section_enabled("calendar", config):
("Tasks", gtasks.collect), collectors.append(("Calendar", gcal.collect))
]) if is_section_enabled("tasks", config):
collectors.append(("Tasks", gtasks.collect))
else: else:
logger.warning("Google collectors not available - run with gmail venv") logger.warning("Google collectors not available - run with gmail venv")
@@ -95,12 +101,14 @@ def collect_all(config: dict) -> list:
results.append(result) results.append(result)
except Exception as e: except Exception as e:
logger.error(f"Future {name} exception: {e}") logger.error(f"Future {name} exception: {e}")
results.append({ results.append(
{
"section": name, "section": name,
"icon": "", "icon": "",
"content": f"⚠️ {name} failed: {e}", "content": f"⚠️ {name} failed: {e}",
"error": str(e) "error": str(e),
}) }
)
return results return results
@@ -111,13 +119,21 @@ def render_report(sections: list, config: dict) -> str:
date_str = now.strftime("%a %b %d, %Y") date_str = now.strftime("%a %b %d, %Y")
time_str = now.strftime("%I:%M %p %Z").strip() time_str = now.strftime("%I:%M %p %Z").strip()
lines = [ lines = [f"# Morning Report - {date_str}", ""]
f"# Morning Report - {date_str}",
""
]
# Order sections # Order sections
order = ["Weather", "Email", "Calendar", "Today", "Stocks", "Tasks", "Infra", "Infrastructure", "News", "Tech News"] order = [
"Weather",
"Email",
"Calendar",
"Today",
"Stocks",
"Tasks",
"Infra",
"Infrastructure",
"News",
"Tech News",
]
# Sort by order # Sort by order
section_map = {s.get("section", ""): s for s in sections} section_map = {s.get("section", ""): s for s in sections}
@@ -137,10 +153,7 @@ def render_report(sections: list, config: dict) -> str:
lines.append("") lines.append("")
# Footer # Footer
lines.extend([ lines.extend(["---", f"*Generated: {now.strftime('%Y-%m-%d %H:%M:%S')} PT*"])
"---",
f"*Generated: {now.strftime('%Y-%m-%d %H:%M:%S')} PT*"
])
return "\n".join(lines) return "\n".join(lines)
@@ -148,7 +161,9 @@ def render_report(sections: list, config: dict) -> str:
def save_report(content: str, config: dict) -> Path: def save_report(content: str, config: dict) -> Path:
"""Save report to file and archive.""" """Save report to file and archive."""
output_config = config.get("output", {}) output_config = config.get("output", {})
output_path = Path(output_config.get("path", "~/.claude/reports/morning.md")).expanduser() output_path = Path(
output_config.get("path", "~/.claude/reports/morning.md")
).expanduser()
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
# Write main report # Write main report