mccloud-assistant 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. mccloud_assistant-0.1.0/PKG-INFO +97 -0
  2. mccloud_assistant-0.1.0/README.md +69 -0
  3. mccloud_assistant-0.1.0/pyproject.toml +46 -0
  4. mccloud_assistant-0.1.0/setup.cfg +4 -0
  5. mccloud_assistant-0.1.0/src/mccloud/__init__.py +0 -0
  6. mccloud_assistant-0.1.0/src/mccloud/__main__.py +9 -0
  7. mccloud_assistant-0.1.0/src/mccloud/app.py +243 -0
  8. mccloud_assistant-0.1.0/src/mccloud/calendar_cli.py +190 -0
  9. mccloud_assistant-0.1.0/src/mccloud/features.py +81 -0
  10. mccloud_assistant-0.1.0/src/mccloud/github_cli.py +255 -0
  11. mccloud_assistant-0.1.0/src/mccloud/panes/__init__.py +0 -0
  12. mccloud_assistant-0.1.0/src/mccloud/panes/_config.py +122 -0
  13. mccloud_assistant-0.1.0/src/mccloud/panes/calendar.py +287 -0
  14. mccloud_assistant-0.1.0/src/mccloud/panes/chat.py +244 -0
  15. mccloud_assistant-0.1.0/src/mccloud/panes/config.py +653 -0
  16. mccloud_assistant-0.1.0/src/mccloud/panes/github.py +258 -0
  17. mccloud_assistant-0.1.0/src/mccloud/panes/notes.py +173 -0
  18. mccloud_assistant-0.1.0/src/mccloud/panes/reminders.py +503 -0
  19. mccloud_assistant-0.1.0/src/mccloud/panes/slack.py +213 -0
  20. mccloud_assistant-0.1.0/src/mccloud/reminders_cli.py +143 -0
  21. mccloud_assistant-0.1.0/src/mccloud/server.py +845 -0
  22. mccloud_assistant-0.1.0/src/mccloud/slack_cli.py +304 -0
  23. mccloud_assistant-0.1.0/src/mccloud_assistant.egg-info/PKG-INFO +97 -0
  24. mccloud_assistant-0.1.0/src/mccloud_assistant.egg-info/SOURCES.txt +26 -0
  25. mccloud_assistant-0.1.0/src/mccloud_assistant.egg-info/dependency_links.txt +1 -0
  26. mccloud_assistant-0.1.0/src/mccloud_assistant.egg-info/entry_points.txt +2 -0
  27. mccloud_assistant-0.1.0/src/mccloud_assistant.egg-info/requires.txt +9 -0
  28. mccloud_assistant-0.1.0/src/mccloud_assistant.egg-info/top_level.txt +1 -0
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: mccloud-assistant
3
+ Version: 0.1.0
4
+ Summary: McCloud — personal AI assistant TUI
5
+ Author: stephen-daq
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/stephen-daq/my-chief-of-staff
8
+ Project-URL: Repository, https://github.com/stephen-daq/my-chief-of-staff
9
+ Keywords: tui,terminal,ai,assistant,claude
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Terminals
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: textual>=0.83.0
21
+ Requires-Dist: anthropic>=0.40
22
+ Requires-Dist: google-api-python-client>=2.0
23
+ Requires-Dist: google-auth-oauthlib>=1.0
24
+ Requires-Dist: slack_sdk>=3.0
25
+ Provides-Extra: server
26
+ Requires-Dist: fastapi>=0.100; extra == "server"
27
+ Requires-Dist: uvicorn>=0.20; extra == "server"
28
+
29
+ # mccloud
30
+
31
+ Personal AI assistant TUI — powered by Claude.
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ pipx install mccloud-assistant
37
+ ```
38
+
39
+ ### From source
40
+
41
+ ```sh
42
+ git clone https://github.com/stephen-daq/mccloud
43
+ cd mccloud
44
+ make install
45
+ ```
46
+
47
+ ## Run
48
+
49
+ ```sh
50
+ mccloud
51
+ ```
52
+
53
+ Or from source:
54
+
55
+ ```sh
56
+ make run
57
+ ```
58
+
59
+ ## Keys
60
+
61
+ | key | pane |
62
+ | --- | --- |
63
+ | `1` | Reminders (tasks + Apple Reminders) |
64
+ | `2` | Calendar (events + meetings) |
65
+ | `3` | Obsidian |
66
+ | `4` | Notion |
67
+ | `5` | Slack |
68
+ | `c` | Chat |
69
+ | `p` | Prompts |
70
+ | `?` | Help |
71
+ | `j / k` | Next / previous pane |
72
+ | `] / [` | Same as j / k |
73
+ | `h` | Focus sidebar |
74
+ | `l` | Focus main content |
75
+ | `q` | Quit |
76
+
77
+ ### Inside Prompts
78
+ | key | action |
79
+ | --- | --- |
80
+ | `↑ / ↓` | Move between features |
81
+ | `e` | Open highlighted prompt in Zed |
82
+
83
+ ## Prompt files
84
+
85
+ Each feature gets a system prompt at `prompts/<feature-id>.md`. Press `e` in the Prompts pane to create and edit one. These files are the instructions Claude will follow when that feature is eventually wired up.
86
+
87
+ ## Layout
88
+
89
+ ```
90
+ src/chief_of_staff/
91
+ app.py # FEATURES list (single source of truth), all panes, ChiefOfStaffApp
92
+ app.tcss # Textual CSS
93
+ __main__.py # entry point — `cos` after `make install`
94
+ prompts/ # per-feature system prompt files (created on demand by pressing e)
95
+ ```
96
+
97
+ Add a new feature by appending a `Feature(...)` to `FEATURES` in `app.py`. The keybinding, sidebar entry, and placeholder pane all flow from that list.
@@ -0,0 +1,69 @@
1
+ # mccloud
2
+
3
+ Personal AI assistant TUI — powered by Claude.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pipx install mccloud-assistant
9
+ ```
10
+
11
+ ### From source
12
+
13
+ ```sh
14
+ git clone https://github.com/stephen-daq/mccloud
15
+ cd mccloud
16
+ make install
17
+ ```
18
+
19
+ ## Run
20
+
21
+ ```sh
22
+ mccloud
23
+ ```
24
+
25
+ Or from source:
26
+
27
+ ```sh
28
+ make run
29
+ ```
30
+
31
+ ## Keys
32
+
33
+ | key | pane |
34
+ | --- | --- |
35
+ | `1` | Reminders (tasks + Apple Reminders) |
36
+ | `2` | Calendar (events + meetings) |
37
+ | `3` | Obsidian |
38
+ | `4` | Notion |
39
+ | `5` | Slack |
40
+ | `c` | Chat |
41
+ | `p` | Prompts |
42
+ | `?` | Help |
43
+ | `j / k` | Next / previous pane |
44
+ | `] / [` | Same as j / k |
45
+ | `h` | Focus sidebar |
46
+ | `l` | Focus main content |
47
+ | `q` | Quit |
48
+
49
+ ### Inside Prompts
50
+ | key | action |
51
+ | --- | --- |
52
+ | `↑ / ↓` | Move between features |
53
+ | `e` | Open highlighted prompt in Zed |
54
+
55
+ ## Prompt files
56
+
57
+ Each feature gets a system prompt at `prompts/<feature-id>.md`. Press `e` in the Prompts pane to create and edit one. These files are the instructions Claude will follow when that feature is eventually wired up.
58
+
59
+ ## Layout
60
+
61
+ ```
62
+ src/chief_of_staff/
63
+ app.py # FEATURES list (single source of truth), all panes, ChiefOfStaffApp
64
+ app.tcss # Textual CSS
65
+ __main__.py # entry point — `cos` after `make install`
66
+ prompts/ # per-feature system prompt files (created on demand by pressing e)
67
+ ```
68
+
69
+ Add a new feature by appending a `Feature(...)` to `FEATURES` in `app.py`. The keybinding, sidebar entry, and placeholder pane all flow from that list.
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "mccloud-assistant"
3
+ version = "0.1.0"
4
+ description = "McCloud — personal AI assistant TUI"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.11"
8
+ authors = [{ name = "stephen-daq" }]
9
+ keywords = ["tui", "terminal", "ai", "assistant", "claude"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Topic :: Terminals",
19
+ ]
20
+ dependencies = [
21
+ "textual>=0.83.0",
22
+ "anthropic>=0.40",
23
+ "google-api-python-client>=2.0",
24
+ "google-auth-oauthlib>=1.0",
25
+ "slack_sdk>=3.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ server = [
30
+ "fastapi>=0.100",
31
+ "uvicorn>=0.20",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/stephen-daq/my-chief-of-staff"
36
+ Repository = "https://github.com/stephen-daq/my-chief-of-staff"
37
+
38
+ [project.scripts]
39
+ mccloud = "mccloud.__main__:main"
40
+
41
+ [build-system]
42
+ requires = ["setuptools>=68", "wheel"]
43
+ build-backend = "setuptools.build_meta"
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,9 @@
1
+ from mccloud.app import McCloudApp
2
+
3
+
4
+ def main() -> None:
5
+ McCloudApp().run()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Horizontal, ScrollableContainer, Vertical
8
+ from textual.widgets import ContentSwitcher, Header, Label, ListItem, ListView, Static
9
+
10
+ from mccloud.features import FEATURES, FEATURES_BY_ID, FEATURE_IDS, Feature
11
+ from mccloud.panes._config import _get_enabled_features, _get_tabs_config, _read_assistant_name, _get_user_name, _GENERAL_CONFIG
12
+ from mccloud.panes.calendar import CalendarPane
13
+ from mccloud.panes.chat import ChatPane
14
+ from mccloud.panes.config import ConfigScreen, GeneralPane, UserPane
15
+ from mccloud.panes.github import GitHubPane
16
+ from mccloud.panes.notes import ObsidianPane
17
+ from mccloud.panes.reminders import RemindersPane
18
+ from mccloud.panes.slack import SlackPane
19
+
20
+
21
+ class FeaturePane(ScrollableContainer):
22
+ def __init__(self, feature: Feature) -> None:
23
+ super().__init__(id=feature.id)
24
+ self.feature = feature
25
+
26
+ def compose(self) -> ComposeResult:
27
+ f = self.feature
28
+ yield Static(f.name, classes="title")
29
+ yield Static(f.summary, classes="summary")
30
+ if f.planned:
31
+ yield Static("Planned", classes="section-header")
32
+ for item in f.planned:
33
+ yield Static(f" • {item}", classes="bullet")
34
+ yield Static("Not yet implemented — UI scaffolding only.", classes="not-implemented")
35
+
36
+
37
+ class HelpPane(ScrollableContainer):
38
+ def compose(self) -> ComposeResult:
39
+ yield Static("Help", classes="title")
40
+ yield Static("Jump to any pane", classes="section-header")
41
+ for i, f in enumerate(_get_enabled_features(), 1):
42
+ yield Static(f" [b]{i}[/b] {f.name}", classes="bullet")
43
+ yield Static(" [b]?[/b] This screen", classes="bullet")
44
+ yield Static("Navigation", classes="section-header")
45
+ yield Static(" [b]j / k[/b] Next / previous pane", classes="bullet")
46
+ yield Static(" [b]] / [[/b] Same as j / k", classes="bullet")
47
+ yield Static(" [b]h[/b] Focus the sidebar nav", classes="bullet")
48
+ yield Static(" [b]l[/b] Focus the main content area", classes="bullet")
49
+ yield Static(" [b]q[/b] Quit", classes="bullet")
50
+ yield Static("Reminders pane", classes="section-header")
51
+ yield Static(" [b]↑ / ↓[/b] Move between reminders", classes="bullet")
52
+ yield Static(" [b]enter[/b] Mark reminder as done", classes="bullet")
53
+ yield Static(" [b]e[/b] Edit reminder title", classes="bullet")
54
+ yield Static(" [b]d[/b] Delete reminder (confirmation required)", classes="bullet")
55
+ yield Static(" [b]a[/b] Add new reminder", classes="bullet")
56
+ yield Static(" [b]r[/b] Refresh from Apple Reminders", classes="bullet")
57
+ yield Static("Prompts pane", classes="section-header")
58
+ yield Static(" [b]↑ / ↓[/b] Move between features", classes="bullet")
59
+ yield Static(" [b]e[/b] Open highlighted skill in editor", classes="bullet")
60
+
61
+
62
+ class AppFooter(Horizontal):
63
+ DEFAULT_CSS = """
64
+ AppFooter {
65
+ dock: bottom;
66
+ background: $panel;
67
+ height: 1;
68
+ padding: 0 1;
69
+ }
70
+ #footer-left { width: 1fr; }
71
+ #footer-right { width: auto; text-align: right; }
72
+ """
73
+
74
+ _LEFT = [("j", "↑"), ("k", "↓"), ("l", "→"), ("h", "←"), ("q", "quit")]
75
+ _RIGHT = [("*", "config"), ("?", "help")]
76
+
77
+ @staticmethod
78
+ def _pill(key: str, desc: str) -> str:
79
+ return (
80
+ f"[bold white on #0e4780] {key} [/bold white on #0e4780]"
81
+ f"[on #071e36] {desc} [/on #071e36]"
82
+ )
83
+
84
+ def compose(self) -> ComposeResult:
85
+ left = " ".join(self._pill(k, d) for k, d in self._LEFT)
86
+ right = " ".join(self._pill(k, d) for k, d in self._RIGHT)
87
+ yield Static(left, markup=True, id="footer-left")
88
+ yield Static(right, markup=True, id="footer-right")
89
+
90
+
91
+ class McCloudApp(App):
92
+ TITLE = "McCloud"
93
+ CSS_PATH = "app.tcss"
94
+
95
+ BINDINGS = [
96
+ Binding("*", "config", "Config", show=False),
97
+ Binding("?", "switch('help')", "Help"),
98
+ Binding("j", "cycle(1)", "Next pane"),
99
+ Binding("k", "cycle(-1)", "Prev pane"),
100
+ Binding("]", "cycle(1)", "", show=False),
101
+ Binding("[", "cycle(-1)", "", show=False),
102
+ Binding("h", "focus_sidebar", "", show=False),
103
+ Binding("l", "focus_main", "", show=False),
104
+ Binding("q", "quit", "Quit"),
105
+ ]
106
+
107
+ def compose(self) -> ComposeResult:
108
+ enabled = _get_enabled_features()
109
+ initial = enabled[0].id if enabled else "help"
110
+ yield Header(show_clock=True)
111
+ with Horizontal(id="body"):
112
+ with Vertical(id="sidebar"):
113
+ yield Static("McCloud", id="brand")
114
+ yield ListView(
115
+ *[
116
+ ListItem(Label(f" [b]{i}[/b] {f.name}"), id=f"nav-{f.id}")
117
+ for i, f in enumerate(enabled, 1)
118
+ ],
119
+ id="nav",
120
+ )
121
+ yield Static("[dim]? for help[/dim]", id="hint")
122
+ with ContentSwitcher(initial=initial, id="main"):
123
+ for f in enabled:
124
+ if f.id == "reminders":
125
+ yield RemindersPane(id="reminders")
126
+ elif f.id == "calendar":
127
+ yield CalendarPane(id="calendar")
128
+ elif f.id == "chat":
129
+ yield ChatPane(id="chat")
130
+ elif f.id == "slack":
131
+ yield SlackPane(id="slack")
132
+ elif f.id == "github":
133
+ yield GitHubPane(id="github")
134
+ elif f.id == "obsidian":
135
+ yield ObsidianPane(id="obsidian")
136
+ else:
137
+ yield FeaturePane(f)
138
+ yield HelpPane(id="help")
139
+ yield AppFooter()
140
+
141
+ def on_mount(self) -> None:
142
+ enabled = _get_enabled_features()
143
+ initial = enabled[0].id if enabled else "help"
144
+ self._sync_sidebar(initial)
145
+ self._apply_assistant_name(_read_assistant_name())
146
+ self._apply_user_name(_get_user_name())
147
+ if _GENERAL_CONFIG.exists():
148
+ try:
149
+ saved_theme = json.loads(_GENERAL_CONFIG.read_text()).get("theme", "")
150
+ if saved_theme and saved_theme in getattr(self, "available_themes", {}):
151
+ self.theme = saved_theme
152
+ except Exception:
153
+ pass
154
+
155
+ def on_key(self, event) -> None:
156
+ if event.character and event.character.isdigit():
157
+ n = int(event.character)
158
+ enabled = _get_enabled_features()
159
+ if 0 < n <= len(enabled):
160
+ self.action_switch(enabled[n - 1].id)
161
+ event.stop()
162
+
163
+ def _apply_assistant_name(self, name: str) -> None:
164
+ self.title = name
165
+ try:
166
+ enabled = _get_enabled_features()
167
+ idx = next((i for i, f in enumerate(enabled, 1) if f.id == "chat"), None)
168
+ if idx is not None:
169
+ label = self.query_one("#nav-chat ListItem Label", Label)
170
+ label.update(f" [b]{idx}[/b] {name}")
171
+ except Exception:
172
+ pass
173
+
174
+ def _apply_user_name(self, name: str) -> None:
175
+ self.sub_title = f"for {name}" if name else ""
176
+
177
+ def on_general_pane_assistant_name_changed(self, event: GeneralPane.AssistantNameChanged) -> None:
178
+ self._apply_assistant_name(event.name)
179
+
180
+ def on_user_pane_user_name_changed(self, event: UserPane.UserNameChanged) -> None:
181
+ self._apply_user_name(event.name)
182
+
183
+ def action_switch(self, target: str) -> None:
184
+ if target in FEATURES_BY_ID and not _get_tabs_config().get(target, True):
185
+ return
186
+ self.query_one(ContentSwitcher).current = target
187
+ self._sync_sidebar(target)
188
+
189
+ def action_config(self) -> None:
190
+ self.push_screen(ConfigScreen())
191
+
192
+ def action_cycle(self, direction: int) -> None:
193
+ from textual.widgets import OptionList
194
+ focused = self.screen.focused
195
+ if isinstance(focused, OptionList):
196
+ focused.action_cursor_down() if direction > 0 else focused.action_cursor_up()
197
+ return
198
+ if isinstance(focused, ListView) and focused.id != "nav":
199
+ focused.action_cursor_down() if direction > 0 else focused.action_cursor_up()
200
+ return
201
+ enabled_ids = [f.id for f in _get_enabled_features()]
202
+ if not enabled_ids:
203
+ return
204
+ current = self.query_one(ContentSwitcher).current or enabled_ids[0]
205
+ if current not in enabled_ids:
206
+ target = enabled_ids[0]
207
+ else:
208
+ target = enabled_ids[(enabled_ids.index(current) + direction) % len(enabled_ids)]
209
+ self.action_switch(target)
210
+
211
+ def action_focus_sidebar(self) -> None:
212
+ self.query_one("#nav", ListView).focus()
213
+
214
+ def action_focus_main(self) -> None:
215
+ cs = self.query_one(ContentSwitcher)
216
+ pane_id = cs.current or FEATURE_IDS[0]
217
+ try:
218
+ pane = self.query_one(f"#{pane_id}")
219
+ if hasattr(pane, "focus_content"):
220
+ pane.focus_content()
221
+ return
222
+ if pane.can_focus:
223
+ pane.focus()
224
+ else:
225
+ for widget in pane.walk_children():
226
+ if widget.can_focus:
227
+ widget.focus()
228
+ return
229
+ except Exception:
230
+ pass
231
+
232
+ def _sync_sidebar(self, target: str) -> None:
233
+ nav = self.query_one("#nav", ListView)
234
+ for i, f in enumerate(_get_enabled_features()):
235
+ if f.id == target:
236
+ nav.index = i
237
+ return
238
+
239
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
240
+ item_id = event.item.id or ""
241
+ target = item_id.removeprefix("nav-")
242
+ if target and target in FEATURES_BY_ID:
243
+ self.action_switch(target)
@@ -0,0 +1,190 @@
1
+ """Google Calendar data layer."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import httplib2
11
+ import google_auth_httplib2
12
+ from google.auth.transport.requests import Request
13
+ from google.oauth2.credentials import Credentials
14
+ from google_auth_oauthlib.flow import InstalledAppFlow
15
+ from googleapiclient.discovery import build
16
+
17
+ SCOPES = ["https://www.googleapis.com/auth/calendar"]
18
+ _CREDS_DIR = Path(__file__).parent.parent.parent / ".credentials"
19
+ _TOKEN_FILE = _CREDS_DIR / "google_token.json"
20
+ _CLIENT_SECRET = next(_CREDS_DIR.glob("client_secret_*.json"), None)
21
+
22
+ _JOIN_RE = re.compile(
23
+ r"https?://(?:"
24
+ r"[\w-]+\.zoom\.us/[^\s<\"']+"
25
+ r"|meet\.google\.com/[^\s<\"']+"
26
+ r"|teams\.microsoft\.com/[^\s<\"']+"
27
+ r"|[\w-]+\.webex\.com/[^\s<\"']+"
28
+ r")"
29
+ )
30
+
31
+
32
+ def is_authenticated() -> bool:
33
+ if not _TOKEN_FILE.exists():
34
+ return False
35
+ try:
36
+ creds = Credentials.from_authorized_user_file(str(_TOKEN_FILE), SCOPES)
37
+ if creds.valid:
38
+ return True
39
+ if creds.expired and creds.refresh_token:
40
+ creds.refresh(Request())
41
+ _TOKEN_FILE.write_text(creds.to_json())
42
+ return True
43
+ except Exception:
44
+ pass
45
+ return False
46
+
47
+
48
+ def authenticate() -> None:
49
+ if not _CLIENT_SECRET:
50
+ raise FileNotFoundError("No client_secret_*.json found in .credentials/")
51
+ flow = InstalledAppFlow.from_client_secrets_file(str(_CLIENT_SECRET), SCOPES)
52
+ creds = flow.run_local_server(port=0)
53
+ _CREDS_DIR.mkdir(exist_ok=True)
54
+ _TOKEN_FILE.write_text(creds.to_json())
55
+
56
+
57
+ def _creds() -> Credentials:
58
+ creds = Credentials.from_authorized_user_file(str(_TOKEN_FILE), SCOPES)
59
+ if creds.expired and creds.refresh_token:
60
+ creds.refresh(Request())
61
+ _TOKEN_FILE.write_text(creds.to_json())
62
+ return creds
63
+
64
+
65
+ @dataclass
66
+ class CalendarEvent:
67
+ id: str
68
+ title: str
69
+ start: datetime
70
+ end: datetime
71
+ all_day: bool = False
72
+ location: str = ""
73
+ description: str = ""
74
+ calendar_name: str = ""
75
+ calendar_id: str = ""
76
+ join_url: str = ""
77
+ html_link: str = ""
78
+
79
+ @property
80
+ def duration_minutes(self) -> int:
81
+ return int((self.end - self.start).total_seconds() / 60)
82
+
83
+
84
+ def _extract_join_url(event: dict) -> str:
85
+ for field in ("location", "description", "hangoutLink"):
86
+ text = event.get(field, "") or ""
87
+ m = _JOIN_RE.search(text)
88
+ if m:
89
+ return m.group(0)
90
+ return ""
91
+
92
+
93
+ def _build_service():
94
+ creds = _creds()
95
+ http = google_auth_httplib2.AuthorizedHttp(creds, http=httplib2.Http(timeout=5))
96
+ return build("calendar", "v3", http=http, cache_discovery=False)
97
+
98
+
99
+ def get_calendars() -> list[dict]:
100
+ service = _build_service()
101
+ items = service.calendarList().list().execute().get("items", [])
102
+ return [{"id": c["id"], "name": c.get("summary", c["id"])} for c in items]
103
+
104
+
105
+ def get_user_domain() -> str:
106
+ """Return the domain of the authenticated user's email, e.g. 'arena-ai.com'."""
107
+ service = _build_service()
108
+ cal = service.calendarList().get(calendarId="primary").execute()
109
+ email = cal.get("id", "")
110
+ return email.split("@")[1] if "@" in email else ""
111
+
112
+
113
+ def add_calendar(email: str) -> str:
114
+ """Subscribe to another person's calendar. Returns the calendar's display name."""
115
+ service = _build_service()
116
+ result = service.calendarList().insert(body={"id": email}).execute()
117
+ return result.get("summary", email)
118
+
119
+
120
+ def remove_calendar(calendar_id: str) -> None:
121
+ """Unsubscribe from a calendar by its ID (usually the owner's email)."""
122
+ service = _build_service()
123
+ service.calendarList().delete(calendarId=calendar_id).execute()
124
+
125
+
126
+ def get_events(days: int = 7) -> list[CalendarEvent]:
127
+ service = _build_service()
128
+
129
+ now = datetime.now(tz=timezone.utc)
130
+ time_min = now.isoformat()
131
+ time_max = now.replace(hour=23, minute=59, second=59).isoformat()
132
+ if days > 1:
133
+ from datetime import timedelta
134
+ time_max = (now + timedelta(days=days - 1)).replace(
135
+ hour=23, minute=59, second=59
136
+ ).isoformat()
137
+
138
+ calendars = service.calendarList().list().execute().get("items", [])
139
+
140
+ events: list[CalendarEvent] = []
141
+ for cal in calendars:
142
+ if cal.get("accessRole") not in ("owner", "writer", "reader"):
143
+ continue
144
+ result = service.events().list(
145
+ calendarId=cal["id"],
146
+ timeMin=time_min,
147
+ timeMax=time_max,
148
+ singleEvents=True,
149
+ orderBy="startTime",
150
+ maxResults=50,
151
+ ).execute()
152
+ for e in result.get("items", []):
153
+ if e.get("status") == "cancelled":
154
+ continue
155
+ start_raw = e.get("start", {})
156
+ end_raw = e.get("end", {})
157
+ all_day = "date" in start_raw and "dateTime" not in start_raw
158
+ if all_day:
159
+ start = datetime.fromisoformat(start_raw["date"]).replace(
160
+ tzinfo=timezone.utc
161
+ )
162
+ end = datetime.fromisoformat(end_raw["date"]).replace(
163
+ tzinfo=timezone.utc
164
+ )
165
+ else:
166
+ start = datetime.fromisoformat(start_raw["dateTime"]).astimezone()
167
+ end = datetime.fromisoformat(end_raw["dateTime"]).astimezone()
168
+ events.append(CalendarEvent(
169
+ id=e["id"],
170
+ title=e.get("summary", "(no title)"),
171
+ start=start,
172
+ end=end,
173
+ all_day=all_day,
174
+ location=e.get("location", ""),
175
+ description=re.sub(r"<[^>]+>", "", e.get("description", "")).strip(),
176
+ calendar_name=cal.get("summary", ""),
177
+ calendar_id=cal["id"],
178
+ join_url=_extract_join_url(e),
179
+ html_link=e.get("htmlLink", ""),
180
+ ))
181
+
182
+ events.sort(key=lambda e: e.start)
183
+ # deduplicate by id (event can appear in multiple calendars)
184
+ seen: set[str] = set()
185
+ unique: list[CalendarEvent] = []
186
+ for e in events:
187
+ if e.id not in seen:
188
+ seen.add(e.id)
189
+ unique.append(e)
190
+ return unique