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.
gw/calendar.py ADDED
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ import click
7
+
8
+ from gw.auth import build_service
9
+ from gw.config import DEFAULT_CONFIG_DIR, GwConfig
10
+ from gw.output import format_output
11
+
12
+
13
+ @click.group()
14
+ @click.pass_context
15
+ def cal(ctx: click.Context) -> None:
16
+ """Google Calendar operations."""
17
+ pass
18
+
19
+
20
+ @cal.command("list")
21
+ @click.option("--days", default=None, type=int, help="Number of days to show")
22
+ @click.pass_context
23
+ def list_events(ctx: click.Context, days: int | None) -> None:
24
+ """List upcoming events."""
25
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
26
+ cfg = GwConfig(config_dir)
27
+ account = cfg.resolve_account(ctx.obj.get("account"))
28
+ output_json = ctx.obj.get("json", False)
29
+
30
+ if days is None:
31
+ default_days = cfg.get_default("calendar", "days")
32
+ days = int(default_days) if default_days is not None else 7
33
+
34
+ service = build_service(cfg, account, "calendar", "v3")
35
+ tz = cfg.get_default("calendar", "timezone") or "Asia/Tokyo"
36
+
37
+ now = datetime.now(timezone.utc).isoformat()
38
+ end = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
39
+
40
+ result = (
41
+ service.events()
42
+ .list(
43
+ calendarId="primary",
44
+ timeMin=now,
45
+ timeMax=end,
46
+ singleEvents=True,
47
+ orderBy="startTime",
48
+ timeZone=tz,
49
+ )
50
+ .execute()
51
+ )
52
+
53
+ events = []
54
+ for item in result.get("items", []):
55
+ start = item.get("start", {}).get("dateTime", item.get("start", {}).get("date", ""))
56
+ end_time = item.get("end", {}).get("dateTime", item.get("end", {}).get("date", ""))
57
+ events.append(
58
+ {
59
+ "id": item["id"],
60
+ "summary": item.get("summary", "(No title)"),
61
+ "start": start,
62
+ "end": end_time,
63
+ "meet": item.get("hangoutLink", ""),
64
+ }
65
+ )
66
+
67
+ click.echo(format_output(events, output_json=output_json, account=account))
68
+
69
+
70
+ @cal.command()
71
+ @click.argument("event_id")
72
+ @click.pass_context
73
+ def get(ctx: click.Context, event_id: str) -> None:
74
+ """Get event details."""
75
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
76
+ cfg = GwConfig(config_dir)
77
+ account = cfg.resolve_account(ctx.obj.get("account"))
78
+ output_json = ctx.obj.get("json", False)
79
+
80
+ service = build_service(cfg, account, "calendar", "v3")
81
+ item = service.events().get(calendarId="primary", eventId=event_id).execute()
82
+
83
+ data = {
84
+ "id": item["id"],
85
+ "summary": item.get("summary", "(No title)"),
86
+ "start": item.get("start", {}).get("dateTime", ""),
87
+ "end": item.get("end", {}).get("dateTime", ""),
88
+ "location": item.get("location", ""),
89
+ "description": item.get("description", ""),
90
+ "meet": item.get("hangoutLink", ""),
91
+ "attendees": [a.get("email", "") for a in item.get("attendees", [])],
92
+ }
93
+ click.echo(format_output(data, output_json=output_json, account=account))
94
+
95
+
96
+ @cal.command()
97
+ @click.option("--title", required=True)
98
+ @click.option("--start", required=True, help="Start time: YYYY-MM-DD HH:MM")
99
+ @click.option("--end", required=True, help="End time: YYYY-MM-DD HH:MM")
100
+ @click.option("--meet", is_flag=True, help="Add Google Meet link")
101
+ @click.option("--attendee", multiple=True, help="Attendee email (repeatable)")
102
+ @click.pass_context
103
+ def create(ctx: click.Context, title: str, start: str, end: str, meet: bool, attendee: tuple[str, ...]) -> None:
104
+ """Create a new event."""
105
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
106
+ cfg = GwConfig(config_dir)
107
+ account = cfg.resolve_account(ctx.obj.get("account"))
108
+ output_json = ctx.obj.get("json", False)
109
+ tz = cfg.get_default("calendar", "timezone") or "Asia/Tokyo"
110
+
111
+ body: dict = {
112
+ "summary": title,
113
+ "start": {"dateTime": _parse_datetime(start, tz), "timeZone": tz},
114
+ "end": {"dateTime": _parse_datetime(end, tz), "timeZone": tz},
115
+ }
116
+
117
+ if attendee:
118
+ body["attendees"] = [{"email": e} for e in attendee]
119
+
120
+ if meet:
121
+ body["conferenceData"] = {
122
+ "createRequest": {
123
+ "requestId": str(uuid.uuid4()),
124
+ "conferenceSolutionKey": {"type": "hangoutsMeet"},
125
+ }
126
+ }
127
+
128
+ service = build_service(cfg, account, "calendar", "v3")
129
+ conference_version = 1 if meet else 0
130
+ result = (
131
+ service.events()
132
+ .insert(
133
+ calendarId="primary",
134
+ body=body,
135
+ conferenceDataVersion=conference_version,
136
+ )
137
+ .execute()
138
+ )
139
+
140
+ data = {
141
+ "id": result["id"],
142
+ "summary": result.get("summary", ""),
143
+ "link": result.get("htmlLink", ""),
144
+ "meet": result.get("hangoutLink", ""),
145
+ }
146
+ click.echo(format_output(data, output_json=output_json, account=account))
147
+
148
+
149
+ @cal.command()
150
+ @click.argument("event_id")
151
+ @click.option("--title", default=None)
152
+ @click.option("--start", default=None, help="New start time: YYYY-MM-DD HH:MM")
153
+ @click.option("--end", default=None, help="New end time: YYYY-MM-DD HH:MM")
154
+ @click.pass_context
155
+ def update(ctx: click.Context, event_id: str, title: str | None, start: str | None, end: str | None) -> None:
156
+ """Update an event."""
157
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
158
+ cfg = GwConfig(config_dir)
159
+ account = cfg.resolve_account(ctx.obj.get("account"))
160
+ output_json = ctx.obj.get("json", False)
161
+ tz = cfg.get_default("calendar", "timezone") or "Asia/Tokyo"
162
+
163
+ service = build_service(cfg, account, "calendar", "v3")
164
+ event = service.events().get(calendarId="primary", eventId=event_id).execute()
165
+
166
+ if title:
167
+ event["summary"] = title
168
+ if start:
169
+ event["start"] = {"dateTime": _parse_datetime(start, tz), "timeZone": tz}
170
+ if end:
171
+ event["end"] = {"dateTime": _parse_datetime(end, tz), "timeZone": tz}
172
+
173
+ result = service.events().update(calendarId="primary", eventId=event_id, body=event).execute()
174
+ click.echo(format_output(f"Updated: {result.get('summary', '')}", output_json=output_json, account=account))
175
+
176
+
177
+ @cal.command()
178
+ @click.argument("event_id")
179
+ @click.pass_context
180
+ def delete(ctx: click.Context, event_id: str) -> None:
181
+ """Delete an event."""
182
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
183
+ cfg = GwConfig(config_dir)
184
+ account = cfg.resolve_account(ctx.obj.get("account"))
185
+ output_json = ctx.obj.get("json", False)
186
+
187
+ service = build_service(cfg, account, "calendar", "v3")
188
+ service.events().delete(calendarId="primary", eventId=event_id).execute()
189
+ click.echo(format_output("Deleted event.", output_json=output_json, account=account))
190
+
191
+
192
+ @cal.command()
193
+ @click.option("--date", required=True, help="Date to check: YYYY-MM-DD")
194
+ @click.option("--account", "account_override", default=None, help="Account email to check")
195
+ @click.pass_context
196
+ def free(ctx: click.Context, date: str, account_override: str | None) -> None:
197
+ """Check free/busy status."""
198
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
199
+ cfg = GwConfig(config_dir)
200
+ # Use the --account option passed to this subcommand, or fall back to ctx.obj
201
+ account = cfg.resolve_account(account_override or ctx.obj.get("account"))
202
+ output_json = ctx.obj.get("json", False)
203
+ tz = cfg.get_default("calendar", "timezone") or "Asia/Tokyo"
204
+
205
+ try:
206
+ next_day = (datetime.strptime(date, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d")
207
+ except ValueError as exc:
208
+ raise click.BadParameter(f"Invalid date {date!r}; expected format 'YYYY-MM-DD'.") from exc
209
+
210
+ service = build_service(cfg, account, "calendar", "v3")
211
+ body = {
212
+ "timeMin": _parse_datetime(f"{date} 00:00", tz),
213
+ "timeMax": _parse_datetime(f"{next_day} 00:00", tz),
214
+ "timeZone": tz,
215
+ "items": [{"id": account}],
216
+ }
217
+ result = service.freebusy().query(body=body).execute()
218
+
219
+ busy_times = []
220
+ for cal_id, cal_data in result.get("calendars", {}).items():
221
+ for busy in cal_data.get("busy", []):
222
+ busy_times.append({"account": cal_id, "start": busy["start"], "end": busy["end"]})
223
+
224
+ if busy_times:
225
+ click.echo(format_output(busy_times, output_json=output_json, account=account))
226
+ else:
227
+ click.echo(format_output(f"No busy times on {date}.", output_json=output_json, account=account))
228
+
229
+
230
+ def _parse_datetime(dt_str: str, tz_name: str) -> str:
231
+ """Parse 'YYYY-MM-DD HH:MM' into RFC3339 with timezone offset."""
232
+ from zoneinfo import ZoneInfo
233
+
234
+ try:
235
+ dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M")
236
+ except ValueError as exc:
237
+ raise click.BadParameter(f"Invalid datetime {dt_str!r}; expected format 'YYYY-MM-DD HH:MM'.") from exc
238
+ dt = dt.replace(tzinfo=ZoneInfo(tz_name))
239
+ return dt.isoformat()
gw/cli.py ADDED
@@ -0,0 +1,119 @@
1
+ import json as json_module
2
+ import sys
3
+
4
+ import click
5
+
6
+ from gw.auth import auth
7
+ from gw.calendar import cal
8
+ from gw.config import DEFAULT_CONFIG_DIR, GwConfig
9
+ from gw.drive import drive
10
+ from gw.gmail import mail
11
+
12
+
13
+ @click.group()
14
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
15
+ @click.option("--account", default=None, help="Google account email to use")
16
+ @click.option(
17
+ "--config-dir",
18
+ type=click.Path(),
19
+ default=None,
20
+ hidden=True,
21
+ help="Config directory override (for testing)",
22
+ )
23
+ @click.pass_context
24
+ def main(ctx: click.Context, output_json: bool, account: str | None, config_dir: str | None) -> None:
25
+ """gw - Google Workspace CLI for Claude Code."""
26
+ ctx.ensure_object(dict)
27
+ ctx.obj["json"] = output_json
28
+ ctx.obj["account"] = account
29
+ if config_dir:
30
+ from pathlib import Path
31
+
32
+ ctx.obj["config_dir"] = Path(config_dir)
33
+
34
+
35
+ main.add_command(auth)
36
+ main.add_command(cal)
37
+ main.add_command(drive)
38
+ main.add_command(mail)
39
+
40
+
41
+ @click.group("config")
42
+ @click.pass_context
43
+ def config_group(ctx: click.Context) -> None:
44
+ """Manage gw-cli configuration."""
45
+ pass
46
+
47
+
48
+ @config_group.command()
49
+ @click.pass_context
50
+ def show(ctx: click.Context) -> None:
51
+ """Show current configuration."""
52
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
53
+ cfg = GwConfig(config_dir)
54
+ data = {
55
+ "active_account": cfg.active_account,
56
+ "accounts": [a["email"] for a in cfg.accounts],
57
+ "defaults": cfg.defaults,
58
+ "loop": cfg.loop,
59
+ }
60
+ click.echo(json_module.dumps(data, indent=2, ensure_ascii=False))
61
+
62
+
63
+ @config_group.command("set")
64
+ @click.argument("key")
65
+ @click.argument("value")
66
+ @click.pass_context
67
+ def set_config(ctx: click.Context, key: str, value: str) -> None:
68
+ """Set a config value (e.g. defaults.calendar.days 14)."""
69
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
70
+ cfg = GwConfig(config_dir)
71
+
72
+ parts = key.split(".")
73
+ if len(parts) == 3 and parts[0] == "defaults":
74
+ converted: object = value
75
+ try:
76
+ converted = int(value)
77
+ except ValueError:
78
+ try:
79
+ converted = float(value)
80
+ except ValueError:
81
+ if value.lower() in ("true", "false"):
82
+ converted = value.lower() == "true"
83
+ elif value.lower() == "null":
84
+ converted = None
85
+
86
+ cfg.set_default(parts[1], parts[2], converted)
87
+ cfg.save()
88
+ click.echo(f"Set {key} = {converted}")
89
+ else:
90
+ click.echo(f"Unknown config key: {key}", err=True)
91
+ raise SystemExit(1)
92
+
93
+
94
+ main.add_command(config_group)
95
+
96
+
97
+ def cli() -> None:
98
+ """Console-script entry point with friendly top-level error handling.
99
+
100
+ Click only renders ClickException subclasses nicely; uncaught RuntimeError
101
+ or ValueError (raised by build_service, resolve_account, config loading,
102
+ etc.) would otherwise surface as a raw Python traceback. Catch those here
103
+ and report a one-line error with a non-zero exit code.
104
+ """
105
+ try:
106
+ main(standalone_mode=False)
107
+ except click.ClickException as exc:
108
+ exc.show()
109
+ sys.exit(exc.exit_code)
110
+ except click.exceptions.Abort:
111
+ click.echo("Aborted.", err=True)
112
+ sys.exit(1)
113
+ except (RuntimeError, ValueError) as exc:
114
+ click.echo(f"Error: {exc}", err=True)
115
+ sys.exit(1)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ cli()
gw/config.py ADDED
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import os
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "google-workspace-cli"
11
+
12
+ DEFAULT_DEFAULTS = {
13
+ "calendar": {"days": 7, "timezone": "Asia/Tokyo"},
14
+ "mail": {"limit": 20, "check_query": "is:unread"},
15
+ "drive": {"default_folder": None},
16
+ }
17
+
18
+
19
+ class GwConfig:
20
+ def __init__(self, config_dir: Path = DEFAULT_CONFIG_DIR) -> None:
21
+ self.config_dir = config_dir
22
+ self.config_path = config_dir / "config.json"
23
+ self.credentials_dir = config_dir / "credentials"
24
+ self.tokens_dir = config_dir / "tokens"
25
+ self._load()
26
+
27
+ def _load(self) -> None:
28
+ if self.config_path.exists():
29
+ data = json.loads(self.config_path.read_text())
30
+ self.active_account: str | None = data.get("active_account")
31
+ self.accounts: list[dict] = data.get("accounts", [])
32
+ self.defaults: dict = data["defaults"] if "defaults" in data else copy.deepcopy(DEFAULT_DEFAULTS)
33
+ self.loop: dict = data.get("loop", {"mail_check_interval": "5m"})
34
+ else:
35
+ self.active_account = None
36
+ self.accounts = []
37
+ self.defaults = copy.deepcopy(DEFAULT_DEFAULTS)
38
+ self.loop = {"mail_check_interval": "5m"}
39
+
40
+ def save(self) -> None:
41
+ self.config_dir.mkdir(parents=True, exist_ok=True)
42
+ self.credentials_dir.mkdir(parents=True, exist_ok=True)
43
+ self.tokens_dir.mkdir(parents=True, exist_ok=True)
44
+ data = {
45
+ "active_account": self.active_account,
46
+ "accounts": self.accounts,
47
+ "defaults": self.defaults,
48
+ "loop": self.loop,
49
+ }
50
+ self.config_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
51
+ os.chmod(self.config_path, 0o600)
52
+
53
+ def add_account(self, email: str, credentials_path: str) -> None:
54
+ for acct in self.accounts:
55
+ if acct["email"] == email:
56
+ return
57
+ self.accounts.append(
58
+ {
59
+ "email": email,
60
+ "credentials": credentials_path,
61
+ "added_at": datetime.now(timezone.utc).isoformat(),
62
+ }
63
+ )
64
+ if self.active_account is None:
65
+ self.active_account = email
66
+ self.save()
67
+
68
+ def switch_account(self, email: str) -> None:
69
+ if not any(a["email"] == email for a in self.accounts):
70
+ raise ValueError(f"Account '{email}' not found. Use 'gw auth login' to add it.")
71
+ self.active_account = email
72
+ self.save()
73
+
74
+ def remove_account(self, email: str) -> None:
75
+ self.accounts = [a for a in self.accounts if a["email"] != email]
76
+ if self.active_account == email:
77
+ self.active_account = self.accounts[0]["email"] if self.accounts else None
78
+ token_path = self.tokens_dir / f"{email}.json"
79
+ if token_path.exists():
80
+ token_path.unlink()
81
+ creds_path = self.credentials_dir / f"{email}.json"
82
+ if creds_path.exists():
83
+ creds_path.unlink()
84
+ self.save()
85
+
86
+ def resolve_account(self, account: str | None) -> str:
87
+ if account is not None:
88
+ if not any(a["email"] == account for a in self.accounts):
89
+ raise ValueError(f"Account '{account}' not found.")
90
+ return account
91
+ if self.active_account is None:
92
+ raise RuntimeError("No active account. Run 'gw auth login' first.")
93
+ return self.active_account
94
+
95
+ def get_credentials_path(self, email: str) -> Path:
96
+ for acct in self.accounts:
97
+ if acct["email"] == email:
98
+ return self.config_dir / acct["credentials"]
99
+ raise ValueError(f"Account '{email}' not found.")
100
+
101
+ def get_token_path(self, email: str) -> Path:
102
+ return self.tokens_dir / f"{email}.json"
103
+
104
+ def get_default(self, service: str, key: str) -> Any:
105
+ return self.defaults.get(service, {}).get(key)
106
+
107
+ def set_default(self, service: str, key: str, value: object) -> None:
108
+ if service not in self.defaults:
109
+ self.defaults[service] = {}
110
+ self.defaults[service][key] = value
gw/drive.py ADDED
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from gw.auth import build_service
9
+ from gw.config import DEFAULT_CONFIG_DIR, GwConfig
10
+ from gw.output import format_output
11
+
12
+ GOOGLE_DOC_TYPES = {
13
+ "doc": "application/vnd.google-apps.document",
14
+ "sheet": "application/vnd.google-apps.spreadsheet",
15
+ "slide": "application/vnd.google-apps.presentation",
16
+ "form": "application/vnd.google-apps.form",
17
+ }
18
+
19
+
20
+ @click.group()
21
+ @click.pass_context
22
+ def drive(ctx: click.Context) -> None:
23
+ """Google Drive operations."""
24
+ pass
25
+
26
+
27
+ @drive.command("list")
28
+ @click.option("--folder", default=None, help="Folder ID to list")
29
+ @click.option("--query", default=None, help="Drive search query")
30
+ @click.pass_context
31
+ def list_files(ctx: click.Context, folder: str | None, query: str | None) -> None:
32
+ """List files in Drive."""
33
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
34
+ cfg = GwConfig(config_dir)
35
+ account = cfg.resolve_account(ctx.obj.get("account"))
36
+ output_json = ctx.obj.get("json", False)
37
+
38
+ service = build_service(cfg, account, "drive", "v3")
39
+
40
+ q_parts = []
41
+ if folder:
42
+ q_parts.append(f"'{folder}' in parents")
43
+ if query:
44
+ q_parts.append(query)
45
+ q_parts.append("trashed = false")
46
+ q = " and ".join(q_parts)
47
+
48
+ result = (
49
+ service.files()
50
+ .list(
51
+ q=q,
52
+ fields="files(id,name,mimeType,modifiedTime,size,webViewLink)",
53
+ orderBy="modifiedTime desc",
54
+ pageSize=50,
55
+ )
56
+ .execute()
57
+ )
58
+
59
+ files = []
60
+ for f in result.get("files", []):
61
+ files.append(
62
+ {
63
+ "id": f["id"],
64
+ "name": f["name"],
65
+ "type": f["mimeType"],
66
+ "modified": f.get("modifiedTime", ""),
67
+ "size": f.get("size", ""),
68
+ "link": f.get("webViewLink", ""),
69
+ }
70
+ )
71
+
72
+ click.echo(format_output(files, output_json=output_json, account=account))
73
+
74
+
75
+ @drive.command()
76
+ @click.argument("file_path", type=click.Path(exists=True))
77
+ @click.option("--folder", default=None, help="Destination folder ID")
78
+ @click.pass_context
79
+ def upload(ctx: click.Context, file_path: str, folder: str | None) -> None:
80
+ """Upload a file to Drive."""
81
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
82
+ cfg = GwConfig(config_dir)
83
+ account = cfg.resolve_account(ctx.obj.get("account"))
84
+ output_json = ctx.obj.get("json", False)
85
+
86
+ from googleapiclient.http import MediaFileUpload
87
+
88
+ service = build_service(cfg, account, "drive", "v3")
89
+ path = Path(file_path)
90
+ mime_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
91
+
92
+ file_metadata: dict = {"name": path.name}
93
+ if folder:
94
+ file_metadata["parents"] = [folder]
95
+
96
+ media = MediaFileUpload(str(path), mimetype=mime_type)
97
+ result = service.files().create(body=file_metadata, media_body=media, fields="id,name,webViewLink").execute()
98
+
99
+ click.echo(
100
+ format_output(
101
+ {
102
+ "id": result["id"],
103
+ "name": result["name"],
104
+ "link": result.get("webViewLink", ""),
105
+ },
106
+ output_json=output_json,
107
+ account=account,
108
+ )
109
+ )
110
+
111
+
112
+ @drive.command()
113
+ @click.option("--type", "doc_type", required=True, type=click.Choice(list(GOOGLE_DOC_TYPES.keys())))
114
+ @click.option("--title", required=True)
115
+ @click.option("--folder", default=None, help="Destination folder ID")
116
+ @click.pass_context
117
+ def create(ctx: click.Context, doc_type: str, title: str, folder: str | None) -> None:
118
+ """Create a new Google Doc/Sheet/Slide."""
119
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
120
+ cfg = GwConfig(config_dir)
121
+ account = cfg.resolve_account(ctx.obj.get("account"))
122
+ output_json = ctx.obj.get("json", False)
123
+
124
+ service = build_service(cfg, account, "drive", "v3")
125
+ file_metadata: dict = {"name": title, "mimeType": GOOGLE_DOC_TYPES[doc_type]}
126
+ if folder:
127
+ file_metadata["parents"] = [folder]
128
+
129
+ result = service.files().create(body=file_metadata, fields="id,name,webViewLink").execute()
130
+
131
+ click.echo(
132
+ format_output(
133
+ {
134
+ "id": result["id"],
135
+ "name": result["name"],
136
+ "link": result.get("webViewLink", ""),
137
+ },
138
+ output_json=output_json,
139
+ account=account,
140
+ )
141
+ )
142
+
143
+
144
+ @drive.command()
145
+ @click.argument("file_id")
146
+ @click.option("--email", required=True, help="Email to share with")
147
+ @click.option("--role", required=True, type=click.Choice(["reader", "writer", "commenter"]))
148
+ @click.pass_context
149
+ def share(ctx: click.Context, file_id: str, email: str, role: str) -> None:
150
+ """Share a file."""
151
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
152
+ cfg = GwConfig(config_dir)
153
+ account = cfg.resolve_account(ctx.obj.get("account"))
154
+ output_json = ctx.obj.get("json", False)
155
+
156
+ service = build_service(cfg, account, "drive", "v3")
157
+ service.permissions().create(
158
+ fileId=file_id,
159
+ body={"type": "user", "role": role, "emailAddress": email},
160
+ ).execute()
161
+ click.echo(format_output(f"Shared {file_id} with {email} as {role}.", output_json=output_json, account=account))
162
+
163
+
164
+ @drive.command()
165
+ @click.argument("file_id")
166
+ @click.option("--email", required=True, help="Email to remove sharing")
167
+ @click.pass_context
168
+ def unshare(ctx: click.Context, file_id: str, email: str) -> None:
169
+ """Remove sharing from a file."""
170
+ config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
171
+ cfg = GwConfig(config_dir)
172
+ account = cfg.resolve_account(ctx.obj.get("account"))
173
+ output_json = ctx.obj.get("json", False)
174
+
175
+ service = build_service(cfg, account, "drive", "v3")
176
+ perms = service.permissions().list(fileId=file_id, fields="permissions(id,emailAddress,role)").execute()
177
+
178
+ perm_id = None
179
+ for p in perms.get("permissions", []):
180
+ if p.get("emailAddress", "").lower() == email.lower():
181
+ perm_id = p["id"]
182
+ break
183
+
184
+ if perm_id is None:
185
+ click.echo(format_output(f"No permission found for {email}.", output_json=output_json, account=account))
186
+ return
187
+
188
+ service.permissions().delete(fileId=file_id, permissionId=perm_id).execute()
189
+ click.echo(format_output(f"Removed {email} from {file_id}.", output_json=output_json, account=account))