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.
- mccloud_assistant-0.1.0/PKG-INFO +97 -0
- mccloud_assistant-0.1.0/README.md +69 -0
- mccloud_assistant-0.1.0/pyproject.toml +46 -0
- mccloud_assistant-0.1.0/setup.cfg +4 -0
- mccloud_assistant-0.1.0/src/mccloud/__init__.py +0 -0
- mccloud_assistant-0.1.0/src/mccloud/__main__.py +9 -0
- mccloud_assistant-0.1.0/src/mccloud/app.py +243 -0
- mccloud_assistant-0.1.0/src/mccloud/calendar_cli.py +190 -0
- mccloud_assistant-0.1.0/src/mccloud/features.py +81 -0
- mccloud_assistant-0.1.0/src/mccloud/github_cli.py +255 -0
- mccloud_assistant-0.1.0/src/mccloud/panes/__init__.py +0 -0
- mccloud_assistant-0.1.0/src/mccloud/panes/_config.py +122 -0
- mccloud_assistant-0.1.0/src/mccloud/panes/calendar.py +287 -0
- mccloud_assistant-0.1.0/src/mccloud/panes/chat.py +244 -0
- mccloud_assistant-0.1.0/src/mccloud/panes/config.py +653 -0
- mccloud_assistant-0.1.0/src/mccloud/panes/github.py +258 -0
- mccloud_assistant-0.1.0/src/mccloud/panes/notes.py +173 -0
- mccloud_assistant-0.1.0/src/mccloud/panes/reminders.py +503 -0
- mccloud_assistant-0.1.0/src/mccloud/panes/slack.py +213 -0
- mccloud_assistant-0.1.0/src/mccloud/reminders_cli.py +143 -0
- mccloud_assistant-0.1.0/src/mccloud/server.py +845 -0
- mccloud_assistant-0.1.0/src/mccloud/slack_cli.py +304 -0
- mccloud_assistant-0.1.0/src/mccloud_assistant.egg-info/PKG-INFO +97 -0
- mccloud_assistant-0.1.0/src/mccloud_assistant.egg-info/SOURCES.txt +26 -0
- mccloud_assistant-0.1.0/src/mccloud_assistant.egg-info/dependency_links.txt +1 -0
- mccloud_assistant-0.1.0/src/mccloud_assistant.egg-info/entry_points.txt +2 -0
- mccloud_assistant-0.1.0/src/mccloud_assistant.egg-info/requires.txt +9 -0
- 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"]
|
|
File without changes
|
|
@@ -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
|