From 45b7e4bcf7fb719fc734a0a41b323ab939fc663a Mon Sep 17 00:00:00 2001 From: OpenCode Test Date: Sun, 4 Jan 2026 23:44:24 -0800 Subject: [PATCH] Improve morning report collectors and add section toggling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- skills/morning-report/config.json | 1 + .../morning-report/scripts/collectors/gcal.py | 66 ++++++++++++------ .../scripts/collectors/gmail.py | 65 +++++++++++------- .../scripts/collectors/gtasks.py | 27 +++++--- skills/morning-report/scripts/generate.py | 67 ++++++++++++------- 5 files changed, 146 insertions(+), 80 deletions(-) diff --git a/skills/morning-report/config.json b/skills/morning-report/config.json index a218281..cd5c2dd 100644 --- a/skills/morning-report/config.json +++ b/skills/morning-report/config.json @@ -25,6 +25,7 @@ "show_tomorrow": true }, "tasks": { + "enabled": false, "max_display": 5, "show_due_dates": true }, diff --git a/skills/morning-report/scripts/collectors/gcal.py b/skills/morning-report/scripts/collectors/gcal.py index a603c3e..5880efb 100755 --- a/skills/morning-report/scripts/collectors/gcal.py +++ b/skills/morning-report/scripts/collectors/gcal.py @@ -9,11 +9,13 @@ from pathlib import Path def fetch_events(mode: str = "today") -> list: """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: # 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: sys.path.insert(0, str(venv_site)) @@ -22,26 +24,32 @@ def fetch_events(mode: str = "today") -> list: service = get_calendar_service() now = datetime.utcnow() - if mode == 'today': + if mode == "today": start = now.replace(hour=0, minute=0, second=0, microsecond=0) end = start + timedelta(days=1) - elif mode == 'tomorrow': - start = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + elif mode == "tomorrow": + start = (now + timedelta(days=1)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) end = start + timedelta(days=1) else: start = now end = now + timedelta(days=7) - events_result = service.events().list( - calendarId='primary', - timeMin=start.isoformat() + 'Z', - timeMax=end.isoformat() + 'Z', - singleEvents=True, - orderBy='startTime', - maxResults=20 - ).execute() + events_result = ( + service.events() + .list( + calendarId="primary", + timeMin=start.isoformat() + "Z", + timeMax=end.isoformat() + "Z", + singleEvents=True, + orderBy="startTime", + maxResults=20, + ) + .execute() + ) - return events_result.get('items', []) + return events_result.get("items", []) except Exception as e: return [{"error": str(e)}] @@ -62,7 +70,9 @@ def format_events(today_events: list, tomorrow_events: list = None) -> str: if "dateTime" in start: # 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") elif "date" in start: 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 end = event.get("end", {}) if "dateTime" in start and "dateTime" in end: - start_dt = datetime.fromisoformat(start["dateTime"].replace("Z", "+00:00")) - end_dt = datetime.fromisoformat(end["dateTime"].replace("Z", "+00:00")) + start_dt = datetime.fromisoformat( + 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) if mins >= 60: hours = 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: duration = f" ({mins}m)" @@ -92,17 +108,23 @@ def format_events(today_events: list, tomorrow_events: list = None) -> str: # Tomorrow preview 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) if count > 0: first = tomorrow_events[0] start = first.get("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") else: 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: lines.append("Tomorrow: No events") @@ -126,7 +148,7 @@ def collect(config: dict) -> dict: "icon": "📅", "content": formatted, "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, } diff --git a/skills/morning-report/scripts/collectors/gmail.py b/skills/morning-report/scripts/collectors/gmail.py index 6748a4d..3338bd9 100755 --- a/skills/morning-report/scripts/collectors/gmail.py +++ b/skills/morning-report/scripts/collectors/gmail.py @@ -11,37 +11,49 @@ from pathlib import Path def fetch_unread_emails(days: int = 7, max_results: int = 15) -> list: """Fetch unread emails directly using gmail_mcp library.""" # 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: # 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: sys.path.insert(0, str(venv_site)) from gmail_mcp.utils.GCP.gmail_auth import get_gmail_service service = get_gmail_service() - results = service.users().messages().list( - userId='me', - q=f'is:unread newer_than:{days}d', - maxResults=max_results - ).execute() + results = ( + service.users() + .messages() + .list( + userId="me", q=f"is:unread newer_than:{days}d", maxResults=max_results + ) + .execute() + ) emails = [] - 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']} - emails.append({ - 'from': headers.get('From', 'Unknown'), - 'subject': headers.get('Subject', '(no subject)'), - 'id': msg['id'] - }) + 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"]} + emails.append( + { + "from": headers.get("From", "Unknown"), + "subject": headers.get("Subject", "(no subject)"), + "id": msg["id"], + } + ) return emails @@ -79,10 +91,17 @@ Output the formatted email section, nothing else.""" try: 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, text=True, - timeout=60 + timeout=60, ) if result.returncode == 0 and result.stdout.strip(): @@ -131,7 +150,7 @@ def collect(config: dict) -> dict: "content": formatted, "raw": emails if not has_error else None, "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, } diff --git a/skills/morning-report/scripts/collectors/gtasks.py b/skills/morning-report/scripts/collectors/gtasks.py index e6c5363..e593265 100755 --- a/skills/morning-report/scripts/collectors/gtasks.py +++ b/skills/morning-report/scripts/collectors/gtasks.py @@ -8,7 +8,7 @@ from datetime import datetime from pathlib import Path # 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: sys.path.insert(0, str(venv_site)) @@ -18,6 +18,7 @@ try: from google_auth_oauthlib.flow import InstalledAppFlow from google.auth.transport.requests import Request from googleapiclient.discovery import build + GOOGLE_API_AVAILABLE = True except ImportError: GOOGLE_API_AVAILABLE = False @@ -57,7 +58,11 @@ def fetch_tasks(max_results: int = 10) -> list: try: creds = get_credentials() 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) @@ -69,12 +74,16 @@ def fetch_tasks(max_results: int = 10) -> list: tasklist_id = tasklists["items"][0]["id"] # Get tasks - results = service.tasks().list( - tasklist=tasklist_id, - maxResults=max_results, - showCompleted=False, - showHidden=False - ).execute() + results = ( + service.tasks() + .list( + tasklist=tasklist_id, + maxResults=max_results, + showCompleted=False, + showHidden=False, + ) + .execute() + ) tasks = results.get("items", []) return tasks @@ -150,7 +159,7 @@ def collect(config: dict) -> dict: "content": formatted, "raw": tasks if not has_error else None, "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, } diff --git a/skills/morning-report/scripts/generate.py b/skills/morning-report/scripts/generate.py index 91f75c1..c8710df 100755 --- a/skills/morning-report/scripts/generate.py +++ b/skills/morning-report/scripts/generate.py @@ -18,6 +18,7 @@ from collectors import weather, stocks, infra, news # These may fail if gmail venv not activated try: from collectors import gmail, gcal, gtasks + GOOGLE_COLLECTORS = True except ImportError: GOOGLE_COLLECTORS = False @@ -29,10 +30,7 @@ LOG_PATH.parent.mkdir(parents=True, exist_ok=True) logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", - handlers=[ - logging.FileHandler(LOG_PATH), - logging.StreamHandler() - ] + handlers=[logging.FileHandler(LOG_PATH), logging.StreamHandler()], ) logger = logging.getLogger(__name__) @@ -58,10 +56,17 @@ def collect_section(name: str, collector_func, config: dict) -> dict: "section": name, "icon": "❓", "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: """Collect all sections in parallel.""" collectors = [ @@ -72,11 +77,12 @@ def collect_all(config: dict) -> list: ] if GOOGLE_COLLECTORS: - collectors.extend([ - ("Email", gmail.collect), - ("Calendar", gcal.collect), - ("Tasks", gtasks.collect), - ]) + if is_section_enabled("email", config): + collectors.append(("Email", gmail.collect)) + if is_section_enabled("calendar", config): + collectors.append(("Calendar", gcal.collect)) + if is_section_enabled("tasks", config): + collectors.append(("Tasks", gtasks.collect)) else: logger.warning("Google collectors not available - run with gmail venv") @@ -95,12 +101,14 @@ def collect_all(config: dict) -> list: results.append(result) except Exception as e: logger.error(f"Future {name} exception: {e}") - results.append({ - "section": name, - "icon": "❓", - "content": f"⚠️ {name} failed: {e}", - "error": str(e) - }) + results.append( + { + "section": name, + "icon": "❓", + "content": f"⚠️ {name} failed: {e}", + "error": str(e), + } + ) return results @@ -111,13 +119,21 @@ def render_report(sections: list, config: dict) -> str: date_str = now.strftime("%a %b %d, %Y") time_str = now.strftime("%I:%M %p %Z").strip() - lines = [ - f"# Morning Report - {date_str}", - "" - ] + lines = [f"# Morning Report - {date_str}", ""] # 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 section_map = {s.get("section", ""): s for s in sections} @@ -137,10 +153,7 @@ def render_report(sections: list, config: dict) -> str: lines.append("") # Footer - lines.extend([ - "---", - f"*Generated: {now.strftime('%Y-%m-%d %H:%M:%S')} PT*" - ]) + lines.extend(["---", f"*Generated: {now.strftime('%Y-%m-%d %H:%M:%S')} PT*"]) return "\n".join(lines) @@ -148,7 +161,9 @@ def render_report(sections: list, config: dict) -> str: def save_report(content: str, config: dict) -> Path: """Save report to file and archive.""" 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) # Write main report