google-workspace-cli 0.1.0__py3-none-any.whl
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.
- google_workspace_cli-0.1.0.dist-info/METADATA +270 -0
- google_workspace_cli-0.1.0.dist-info/RECORD +13 -0
- google_workspace_cli-0.1.0.dist-info/WHEEL +4 -0
- google_workspace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- google_workspace_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- gw/__init__.py +1 -0
- gw/auth.py +199 -0
- gw/calendar.py +239 -0
- gw/cli.py +119 -0
- gw/config.py +110 -0
- gw/drive.py +189 -0
- gw/gmail.py +366 -0
- gw/output.py +35 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: google-workspace-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Google Workspace CLI for Claude Code — Calendar, Gmail, Drive with multi-account OAuth support
|
|
5
|
+
Project-URL: Homepage, https://github.com/JFK/gw-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/JFK/gw-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/JFK/gw-cli/issues
|
|
8
|
+
Author-email: Fumikazu Kiyota <fumikazu.kiyota@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: calendar,claude-code,cli,gmail,google-drive,google-workspace,multi-account,oauth2
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Communications :: Email
|
|
20
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: click>=8.3
|
|
23
|
+
Requires-Dist: google-api-python-client>=2.194
|
|
24
|
+
Requires-Dist: google-auth-oauthlib>=1.3
|
|
25
|
+
Requires-Dist: google-auth>=2.49
|
|
26
|
+
Requires-Dist: rich>=15.0
|
|
27
|
+
Requires-Dist: tzdata>=2024.1; platform_system == 'Windows'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# gw-cli
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/google-workspace-cli/)
|
|
33
|
+
[](https://pypi.org/project/google-workspace-cli/)
|
|
34
|
+
[](https://github.com/JFK/gw-cli/blob/master/LICENSE)
|
|
35
|
+
[](https://github.com/JFK/gw-cli/actions/workflows/ci.yml)
|
|
36
|
+
|
|
37
|
+
> **Google Workspace CLI for Claude Code — manage Calendar, Gmail, and Drive across multiple Google accounts.**
|
|
38
|
+
|
|
39
|
+
`gw-cli` is a [Claude Code](https://claude.com/claude-code) plugin that brings Google Workspace into your terminal. Register multiple OAuth accounts, switch between them instantly, and let Claude Code operate your calendar, email, and files through natural language.
|
|
40
|
+
|
|
41
|
+
## 60-second quickstart
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
# In any Claude Code session:
|
|
45
|
+
/plugin marketplace add JFK/gw-cli
|
|
46
|
+
/plugin install gw-cli
|
|
47
|
+
|
|
48
|
+
# Setup (one-time):
|
|
49
|
+
gw auth login --credentials ./credentials.json
|
|
50
|
+
|
|
51
|
+
# Then use naturally:
|
|
52
|
+
"Show me my unread emails"
|
|
53
|
+
"Schedule a meeting tomorrow at 10am with Meet"
|
|
54
|
+
"What's on my calendar this week?"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Features
|
|
58
|
+
|
|
59
|
+
- **Multi-account support** — Register multiple Google accounts (personal, work, client projects) and switch between them instantly
|
|
60
|
+
- **Calendar** — List, create, update, delete events. Google Meet setup. Free/busy check across accounts
|
|
61
|
+
- **Gmail** — Search, read, send, reply. Label management. Mark read/unread. Attachment download and save to Drive
|
|
62
|
+
- **Drive** — List, search, upload, create Google Docs/Sheets/Slides. Share/unshare files
|
|
63
|
+
- **Claude Code integration** — All commands support `--json` output for seamless skill parsing
|
|
64
|
+
- **Periodic monitoring** — Use with `/loop` for unread email checks, calendar reminders
|
|
65
|
+
- **WSL2 compatible** — Automatic fallback to manual OAuth flow when localhost callback is unreachable
|
|
66
|
+
|
|
67
|
+
## Required dependencies
|
|
68
|
+
|
|
69
|
+
| Tool | Required | Why |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| Python 3.10+ | yes | Runtime |
|
|
72
|
+
| `pip` | yes | Package installation |
|
|
73
|
+
| `gcloud` CLI | recommended | GCP project setup and API enablement |
|
|
74
|
+
| [Claude Code](https://claude.com/claude-code) | recommended | Plugin integration and natural language interface |
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
### As a Claude Code plugin (recommended)
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
/plugin marketplace add JFK/gw-cli
|
|
82
|
+
/plugin install gw-cli
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The plugin auto-installs the Python CLI and registers 7 skills:
|
|
86
|
+
|
|
87
|
+
| Skill | Description |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `/gw-setup` | Guided first-time Google Cloud setup |
|
|
90
|
+
| `/gw-auth` | Account management (login, switch, list, remove) |
|
|
91
|
+
| `/gw-calendar` | Calendar operations |
|
|
92
|
+
| `/gw-mail` | Gmail operations |
|
|
93
|
+
| `/gw-drive` | Drive operations |
|
|
94
|
+
| `/gw-workflow` | Cross-service workflows (email attachment to Drive, etc.) |
|
|
95
|
+
| `/gw-loop` | Periodic monitoring setup |
|
|
96
|
+
|
|
97
|
+
### Standalone CLI
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install google-workspace-cli
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
> Published on PyPI as **`google-workspace-cli`** (the `gw-cli` name was already taken). The installed command is still `gw`.
|
|
104
|
+
|
|
105
|
+
Latest from source:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pip install git+https://github.com/JFK/gw-cli.git
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Or clone and install locally:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
git clone https://github.com/JFK/gw-cli.git
|
|
115
|
+
cd gw-cli
|
|
116
|
+
pip install -e .
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Setup
|
|
120
|
+
|
|
121
|
+
### 1. Create GCP Project
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
gcloud projects create gw-cli-$(date +%Y%m%d) --name="gw-cli"
|
|
125
|
+
gcloud config set project gw-cli-YYYYMMDD
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 2. Enable APIs
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
gcloud services enable calendar-json.googleapis.com gmail.googleapis.com drive.googleapis.com --project=PROJECT_ID
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 3. OAuth Consent Screen
|
|
135
|
+
|
|
136
|
+
Open in browser: `https://console.cloud.google.com/apis/credentials/consent?project=PROJECT_ID`
|
|
137
|
+
|
|
138
|
+
- User Type: **External**
|
|
139
|
+
- App name: `gw-cli`
|
|
140
|
+
- Add your email as a **test user** (required, otherwise login fails with 403)
|
|
141
|
+
|
|
142
|
+
### 4. Create OAuth Client
|
|
143
|
+
|
|
144
|
+
Open in browser: `https://console.cloud.google.com/apis/credentials/oauthclient?project=PROJECT_ID`
|
|
145
|
+
|
|
146
|
+
- Application type: **Desktop app**
|
|
147
|
+
- Download the JSON file
|
|
148
|
+
|
|
149
|
+
### 5. Login
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
gw auth login --credentials ./credentials.json
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
On WSL2/remote environments, the tool automatically falls back to manual URL paste mode when the localhost callback is unreachable.
|
|
156
|
+
|
|
157
|
+
### 6. Verify
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
gw auth status
|
|
161
|
+
gw cal list --days 3
|
|
162
|
+
gw mail list --query "is:unread" --limit 5
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Usage
|
|
166
|
+
|
|
167
|
+
### Available commands
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
gw auth login, list, status, switch, remove
|
|
171
|
+
gw cal list, get, create, update, delete, free
|
|
172
|
+
gw mail list, read, send, reply, labels, label, mark, attachments, download, to-drive
|
|
173
|
+
gw drive list, upload, create, share, unshare
|
|
174
|
+
gw config show, set
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Calendar
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
gw cal list --days 7
|
|
181
|
+
gw cal create --title "Meeting" --start "2026-04-17 10:00" --end "2026-04-17 11:00" --meet
|
|
182
|
+
gw cal create --title "1on1" --start "2026-04-18 14:00" --end "2026-04-18 14:30" --meet --attendee colleague@example.com
|
|
183
|
+
gw cal free --date 2026-04-17
|
|
184
|
+
gw cal delete EVENT_ID
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Gmail
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
gw mail list --query "is:unread"
|
|
191
|
+
gw mail read MESSAGE_ID
|
|
192
|
+
gw mail send --to "user@example.com" --subject "Hello" --body "Hi there"
|
|
193
|
+
gw mail reply MESSAGE_ID --body "Thanks!"
|
|
194
|
+
gw mail mark MESSAGE_ID --read
|
|
195
|
+
gw mail attachments MESSAGE_ID
|
|
196
|
+
gw mail to-drive MESSAGE_ID --attachment-id ATT_ID --folder FOLDER_ID
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Drive
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
gw drive list
|
|
203
|
+
gw drive list --query "name contains 'report'"
|
|
204
|
+
gw drive upload ./file.pdf
|
|
205
|
+
gw drive create --type doc --title "New Document"
|
|
206
|
+
gw drive share FILE_ID --email user@example.com --role writer
|
|
207
|
+
gw drive unshare FILE_ID --email user@example.com
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Multi-account
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
gw auth login --credentials ./work-credentials.json # Add another account
|
|
214
|
+
gw auth list # Show all accounts
|
|
215
|
+
gw auth switch work@company.com # Switch active account
|
|
216
|
+
gw mail list # Uses active account
|
|
217
|
+
gw mail list --account personal@gmail.com # Override for one command
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Config
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
gw config show
|
|
224
|
+
gw config set defaults.calendar.days 14
|
|
225
|
+
gw config set defaults.mail.limit 50
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Output
|
|
229
|
+
|
|
230
|
+
All commands support `--json` for machine-readable output:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
gw cal list --json
|
|
234
|
+
gw mail list --query "is:unread" --json
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Human-readable output always shows the active account:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
[fumikazu.kiyota@gmail.com]
|
|
241
|
+
evt1 | Team standup | 2026-04-17T10:00:00+09:00 | ...
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Periodic Monitoring
|
|
245
|
+
|
|
246
|
+
Use with the `/loop` skill for continuous monitoring:
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
/loop 5m gw mail list --query "is:unread" --json
|
|
250
|
+
/loop 30m gw cal list --days 1 --json
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Config storage
|
|
254
|
+
|
|
255
|
+
Config stored at `~/.config/google-workspace-cli/config.json`.
|
|
256
|
+
|
|
257
|
+
Credentials and tokens are stored per account:
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
~/.config/google-workspace-cli/
|
|
261
|
+
├── config.json
|
|
262
|
+
├── credentials/
|
|
263
|
+
│ └── user@gmail.com.json
|
|
264
|
+
└── tokens/
|
|
265
|
+
└── user@gmail.com.json
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
gw/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
gw/auth.py,sha256=tovl1N7FNcg1SJWqe3FfOO3vBus81O8k6iXoaRIQYIg,7287
|
|
3
|
+
gw/calendar.py,sha256=5RIXyiasVRqIfDWhiJzg5XDo5NA0Ey4E7bGHQ-Ab1RQ,8823
|
|
4
|
+
gw/cli.py,sha256=at8JikU302isNawNgxzC0azCKrHrIzRECyC4Ee0gzpk,3424
|
|
5
|
+
gw/config.py,sha256=IjKG6eILcRxgwUEVGWzjNSNqx5LZOjm_7fXpENIN0Ko,4226
|
|
6
|
+
gw/drive.py,sha256=INdU6NZ8RdMs0DKg9QMb0AD4iyQKSCKhzPruVET6cwU,6387
|
|
7
|
+
gw/gmail.py,sha256=BPUuyxnlyNTBzOI2UMtkWRozQ96duZA6fy6ll0_GHUs,13299
|
|
8
|
+
gw/output.py,sha256=c0RYrnvgIXftMuwTFZnRdYTBLMkAPHUFKyANvSRsB1Y,833
|
|
9
|
+
google_workspace_cli-0.1.0.dist-info/METADATA,sha256=eCy8gE4Nv_0vxdJ58yOgEUjTsw53XPyw3X5SMQQ0r1Q,8151
|
|
10
|
+
google_workspace_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
google_workspace_cli-0.1.0.dist-info/entry_points.txt,sha256=TZgF92FFR2flxwiXWddhth8EI3AxYdIMdEPnBHQgkZ4,34
|
|
12
|
+
google_workspace_cli-0.1.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
|
|
13
|
+
google_workspace_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
gw/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
gw/auth.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
10
|
+
from googleapiclient.discovery import build
|
|
11
|
+
|
|
12
|
+
from gw.config import DEFAULT_CONFIG_DIR, GwConfig
|
|
13
|
+
|
|
14
|
+
SCOPES = [
|
|
15
|
+
"https://www.googleapis.com/auth/calendar",
|
|
16
|
+
"https://www.googleapis.com/auth/gmail.modify",
|
|
17
|
+
"https://www.googleapis.com/auth/gmail.send",
|
|
18
|
+
"https://www.googleapis.com/auth/drive",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _can_auto_open_browser() -> bool:
|
|
23
|
+
"""Whether auto-opening a browser is likely to reach the user.
|
|
24
|
+
|
|
25
|
+
On WSL2 and headless hosts the OS default URL handler is usually wrong (e.g. a
|
|
26
|
+
file manager) or absent, so an auto-opened consent page never appears. In those
|
|
27
|
+
cases the caller should skip the auto-open and let the operator open the printed
|
|
28
|
+
URL in any browser that can reach this host's localhost (a Windows browser
|
|
29
|
+
reaches WSL2 localhost). On native desktops (macOS / Windows / Linux with a
|
|
30
|
+
display) auto-open is the convenient one-step path.
|
|
31
|
+
"""
|
|
32
|
+
if os.environ.get("WSL_DISTRO_NAME"):
|
|
33
|
+
return False
|
|
34
|
+
try:
|
|
35
|
+
with open("/proc/version", encoding="utf-8") as fh:
|
|
36
|
+
if "microsoft" in fh.read().lower():
|
|
37
|
+
return False
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
|
|
41
|
+
return False
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_user_email(credentials) -> str:
|
|
46
|
+
"""Fetch the authenticated user's email via Gmail API."""
|
|
47
|
+
service = build("gmail", "v1", credentials=credentials)
|
|
48
|
+
profile = service.users().getProfile(userId="me").execute()
|
|
49
|
+
return profile["emailAddress"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _resolve_config_dir(ctx: click.Context, config_dir: Path | None) -> Path:
|
|
53
|
+
"""Resolve config_dir from subcommand option, parent context, or default."""
|
|
54
|
+
if config_dir is not None:
|
|
55
|
+
return config_dir
|
|
56
|
+
return ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_config_dir_option = click.option(
|
|
60
|
+
"--config-dir",
|
|
61
|
+
type=click.Path(path_type=Path),
|
|
62
|
+
default=None,
|
|
63
|
+
hidden=True,
|
|
64
|
+
help="Config directory (default: ~/.config/google-workspace-cli)",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@click.group()
|
|
69
|
+
@click.pass_context
|
|
70
|
+
def auth(ctx: click.Context) -> None:
|
|
71
|
+
"""Manage Google accounts."""
|
|
72
|
+
ctx.ensure_object(dict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@auth.command()
|
|
76
|
+
@click.option("--credentials", required=True, type=click.Path(exists=True, path_type=Path))
|
|
77
|
+
@_config_dir_option
|
|
78
|
+
@click.pass_context
|
|
79
|
+
def login(ctx: click.Context, credentials: Path, config_dir: Path | None) -> None:
|
|
80
|
+
"""Add a Google account via OAuth login."""
|
|
81
|
+
resolved_dir = _resolve_config_dir(ctx, config_dir)
|
|
82
|
+
cfg = GwConfig(resolved_dir)
|
|
83
|
+
|
|
84
|
+
flow = InstalledAppFlow.from_client_secrets_file(str(credentials), SCOPES)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
# Loopback flow. Always print the consent URL explicitly (don't rely on
|
|
88
|
+
# the library's default prompt message) and keep the local server
|
|
89
|
+
# listening long enough for the operator to authorize. Only auto-open a
|
|
90
|
+
# browser where one is likely to reach the user: on WSL2/headless the OS
|
|
91
|
+
# default URL handler is often wrong (e.g. a file manager), so we skip the
|
|
92
|
+
# auto-open and let the operator open the printed URL in any browser that
|
|
93
|
+
# can reach this host's localhost (a Windows browser reaches WSL2 localhost).
|
|
94
|
+
flow.run_local_server(
|
|
95
|
+
port=0,
|
|
96
|
+
open_browser=_can_auto_open_browser(),
|
|
97
|
+
authorization_prompt_message=("\nOpen this URL in your browser to authorize:\n\n{url}\n"),
|
|
98
|
+
timeout_seconds=300,
|
|
99
|
+
)
|
|
100
|
+
except Exception:
|
|
101
|
+
# Last-resort manual paste (requires an interactive terminal for the prompt).
|
|
102
|
+
auth_url, _ = flow.authorization_url(prompt="consent")
|
|
103
|
+
click.echo(f"\nOpen this URL in your browser:\n\n{auth_url}\n")
|
|
104
|
+
click.echo("After authorizing, you will be redirected to a localhost URL.")
|
|
105
|
+
click.echo("Copy the FULL URL from the browser address bar and paste it here:\n")
|
|
106
|
+
redirect_url = click.prompt("Redirect URL")
|
|
107
|
+
flow.fetch_token(authorization_response=redirect_url)
|
|
108
|
+
|
|
109
|
+
creds = flow.credentials
|
|
110
|
+
email = _get_user_email(creds)
|
|
111
|
+
|
|
112
|
+
dest_creds = cfg.credentials_dir / f"{email}.json"
|
|
113
|
+
cfg.credentials_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
shutil.copy2(credentials, dest_creds)
|
|
115
|
+
os.chmod(dest_creds, 0o600)
|
|
116
|
+
|
|
117
|
+
cfg.tokens_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
token_path = cfg.tokens_dir / f"{email}.json"
|
|
119
|
+
token_path.write_text(creds.to_json())
|
|
120
|
+
os.chmod(token_path, 0o600)
|
|
121
|
+
|
|
122
|
+
cfg.add_account(email, f"credentials/{email}.json")
|
|
123
|
+
click.echo(f"Logged in as {email}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@auth.command("list")
|
|
127
|
+
@_config_dir_option
|
|
128
|
+
@click.pass_context
|
|
129
|
+
def list_accounts(ctx: click.Context, config_dir: Path | None) -> None:
|
|
130
|
+
"""List registered accounts."""
|
|
131
|
+
resolved_dir = _resolve_config_dir(ctx, config_dir)
|
|
132
|
+
cfg = GwConfig(resolved_dir)
|
|
133
|
+
if not cfg.accounts:
|
|
134
|
+
click.echo("No accounts registered. Run 'gw auth login' to add one.")
|
|
135
|
+
return
|
|
136
|
+
for acct in cfg.accounts:
|
|
137
|
+
marker = " *" if acct["email"] == cfg.active_account else ""
|
|
138
|
+
click.echo(f" {acct['email']}{marker}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@auth.command()
|
|
142
|
+
@_config_dir_option
|
|
143
|
+
@click.pass_context
|
|
144
|
+
def status(ctx: click.Context, config_dir: Path | None) -> None:
|
|
145
|
+
"""Show the active account."""
|
|
146
|
+
resolved_dir = _resolve_config_dir(ctx, config_dir)
|
|
147
|
+
cfg = GwConfig(resolved_dir)
|
|
148
|
+
if cfg.active_account:
|
|
149
|
+
click.echo(f"Active account: {cfg.active_account}")
|
|
150
|
+
else:
|
|
151
|
+
click.echo("No active account. Run 'gw auth login' to add one.")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@auth.command()
|
|
155
|
+
@click.argument("email")
|
|
156
|
+
@_config_dir_option
|
|
157
|
+
@click.pass_context
|
|
158
|
+
def switch(ctx: click.Context, email: str, config_dir: Path | None) -> None:
|
|
159
|
+
"""Switch the active account."""
|
|
160
|
+
resolved_dir = _resolve_config_dir(ctx, config_dir)
|
|
161
|
+
cfg = GwConfig(resolved_dir)
|
|
162
|
+
cfg.switch_account(email)
|
|
163
|
+
click.echo(f"Switched to {email}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@auth.command()
|
|
167
|
+
@click.argument("email")
|
|
168
|
+
@_config_dir_option
|
|
169
|
+
@click.pass_context
|
|
170
|
+
def remove(ctx: click.Context, email: str, config_dir: Path | None) -> None:
|
|
171
|
+
"""Remove a registered account."""
|
|
172
|
+
resolved_dir = _resolve_config_dir(ctx, config_dir)
|
|
173
|
+
cfg = GwConfig(resolved_dir)
|
|
174
|
+
cfg.remove_account(email)
|
|
175
|
+
click.echo(f"Removed {email}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def build_service(cfg: GwConfig, email: str, api_name: str, api_version: str):
|
|
179
|
+
"""Build an authenticated Google API service for the given account."""
|
|
180
|
+
from google.auth.transport.requests import Request
|
|
181
|
+
from google.oauth2.credentials import Credentials
|
|
182
|
+
|
|
183
|
+
token_path = cfg.get_token_path(email)
|
|
184
|
+
if not token_path.exists():
|
|
185
|
+
raise RuntimeError(f"No token found for {email}. Run 'gw auth login --credentials <path>' first.")
|
|
186
|
+
|
|
187
|
+
creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
|
|
188
|
+
|
|
189
|
+
if creds.expired and creds.refresh_token:
|
|
190
|
+
try:
|
|
191
|
+
creds.refresh(Request())
|
|
192
|
+
token_path.write_text(creds.to_json())
|
|
193
|
+
os.chmod(token_path, 0o600)
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
raise RuntimeError(
|
|
196
|
+
f"Token refresh failed for {email}. Run 'gw auth login --credentials <path>' to re-authenticate."
|
|
197
|
+
) from exc
|
|
198
|
+
|
|
199
|
+
return build(api_name, api_version, credentials=creds)
|