gmcli 0.1.0__tar.gz → 0.1.2__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 (62) hide show
  1. {gmcli-0.1.0 → gmcli-0.1.2}/CLAUDE.md +18 -0
  2. {gmcli-0.1.0 → gmcli-0.1.2}/PKG-INFO +16 -3
  3. {gmcli-0.1.0 → gmcli-0.1.2}/README.md +15 -2
  4. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/__init__.py +1 -1
  5. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/auth/flow.py +111 -4
  6. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/cli.py +51 -2
  7. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/auth.py +44 -2
  8. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/setup.py +35 -6
  9. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/config.py +25 -0
  10. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/app.py +72 -18
  11. {gmcli-0.1.0 → gmcli-0.1.2}/tests/conftest.py +5 -0
  12. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_client_config.py +124 -1
  13. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_commands.py +144 -0
  14. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_ui.py +74 -0
  15. {gmcli-0.1.0 → gmcli-0.1.2}/.github/workflows/release.yml +0 -0
  16. {gmcli-0.1.0 → gmcli-0.1.2}/.gitignore +0 -0
  17. {gmcli-0.1.0 → gmcli-0.1.2}/LICENSE +0 -0
  18. {gmcli-0.1.0 → gmcli-0.1.2}/pyproject.toml +0 -0
  19. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/__main__.py +0 -0
  20. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/__init__.py +0 -0
  21. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/attachments.py +0 -0
  22. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/client.py +0 -0
  23. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/compose.py +0 -0
  24. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/labels.py +0 -0
  25. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/messages.py +0 -0
  26. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/threads.py +0 -0
  27. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/auth/__init__.py +0 -0
  28. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/auth/client_config.py +0 -0
  29. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/auth/store.py +0 -0
  30. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/bodytext.py +0 -0
  31. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/cache.py +0 -0
  32. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/__init__.py +0 -0
  33. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/attachments.py +0 -0
  34. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/cache_cmd.py +0 -0
  35. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/draft.py +0 -0
  36. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/labels.py +0 -0
  37. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/listing.py +0 -0
  38. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/modify.py +0 -0
  39. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/read.py +0 -0
  40. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/send.py +0 -0
  41. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/ui_cmd.py +0 -0
  42. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/context.py +0 -0
  43. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/errors.py +0 -0
  44. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/idref.py +0 -0
  45. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/models.py +0 -0
  46. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/output.py +0 -0
  47. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/__init__.py +0 -0
  48. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/graphics.py +0 -0
  49. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/keys.py +0 -0
  50. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/render.py +0 -0
  51. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/state.py +0 -0
  52. {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/update.py +0 -0
  53. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_attachments.py +0 -0
  54. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_bodytext.py +0 -0
  55. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_compose.py +0 -0
  56. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_documented_usage.py +0 -0
  57. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_idref.py +0 -0
  58. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_models.py +0 -0
  59. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_output.py +0 -0
  60. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_store.py +0 -0
  61. {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_update.py +0 -0
  62. {gmcli-0.1.0 → gmcli-0.1.2}/uv.lock +0 -0
@@ -160,6 +160,16 @@ Consequences when editing `cli.py`:
160
160
  near the bottom, or its commands will not be guarded.
161
161
  - To signal a failure, `raise` the appropriate `GmcliError` subclass with a
162
162
  `hint=` — do not call `sys.exit` or print-and-return.
163
+ - An **option callback** (`--version`, `--upgrade`, `--account`) runs *outside*
164
+ those wrappers, so it must `_report` and `raise typer.Exit(code=…)` itself.
165
+
166
+ The root callback is `invoke_without_command=True`, so global options with no
167
+ command reach `_no_command()` instead of stopping at Click's bare "Missing
168
+ command.": it prints the same help bare `gmail` does and exits 2 — except
169
+ under `--json`, where the help would land on the pipe, so the error goes to
170
+ stderr instead. `_account_callback` rejects an `--account` value with no `@`
171
+ in it, because the parser hands the option whatever token follows and
172
+ `gmail --account -A` otherwise made `-A` the mailbox name.
163
173
 
164
174
  ### Two command registration styles
165
175
 
@@ -178,6 +188,14 @@ called at the *bottom* of `commands/auth.py` (`setup.py` imports nothing from
178
188
  `auth.py`, so there is no cycle). It also reorders itself to the front of the
179
189
  group's help, since it is where a new user starts.
180
190
 
191
+ `commands/auth.py` uses *both* styles: its `app` group is added with
192
+ `add_typer`, and its `register(root)` additionally attaches `gmail login` and
193
+ `gmail logout` to the root app — the same two callbacks, not copies, so the
194
+ short spellings cannot drift from `gmail auth login`/`logout`. Each
195
+ registration gets its own `_guard` wrapper, which is all `cli.py` needs.
196
+ `test_the_two_spellings_of_login_and_logout_cannot_drift` compares the two
197
+ parameter lists.
198
+
181
199
  ### The `ui/` package
182
200
 
183
201
  Five modules, and the split is load-bearing:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gmcli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Manage Gmail from the terminal: read, search, send, and organize mail over the Gmail API.
5
5
  Project-URL: Homepage, https://github.com/snehangshu2002/gmcli
6
6
  Project-URL: Issues, https://github.com/snehangshu2002/gmcli/issues
@@ -190,9 +190,17 @@ Then hand it over:
190
190
  $ gmail auth login --credentials ~/Downloads/client_secret_1234.json
191
191
  ```
192
192
 
193
- gmcli copies the file into its own data directory at mode `0600`, so later
193
+ gmcli installs the file into its own data directory at mode `0600`, so later
194
194
  logins need no flag.
195
195
 
196
+ A file that came out of `~/Downloads` or `~/Desktop` is **moved** rather than
197
+ copied — the browser wrote a working OAuth client into a directory that syncs,
198
+ backs up, and gets shared out of, and gmcli will not leave a second copy of it
199
+ there. The destination is written first and the original removed only once that
200
+ succeeded, so there is always at least one readable copy. Pass
201
+ `--keep-download` to leave the file where it is; a client anywhere else is
202
+ copied and never touched.
203
+
196
204
  </details>
197
205
 
198
206
  Check it worked:
@@ -568,12 +576,17 @@ Exit codes:
568
576
 
569
577
  ```console
570
578
  $ gmail auth setup # once, first account
571
- $ gmail auth login # repeat per account
579
+ $ gmail login # repeat per account
572
580
  $ gmail auth list
573
581
  $ gmail auth switch work@example.com # set the default
574
582
  $ gmail --account personal@gmail.com ls # override per command
583
+ $ gmail logout # forget this machine's copy
584
+ $ gmail logout --all # every account
575
585
  ```
576
586
 
587
+ `gmail login` and `gmail logout` are the same commands as `gmail auth login`
588
+ and `gmail auth logout`, spelled where you would first look for them.
589
+
577
590
  ## Configuration
578
591
 
579
592
  `~/.config/gmcli/config.toml` (XDG-aware):
@@ -153,9 +153,17 @@ Then hand it over:
153
153
  $ gmail auth login --credentials ~/Downloads/client_secret_1234.json
154
154
  ```
155
155
 
156
- gmcli copies the file into its own data directory at mode `0600`, so later
156
+ gmcli installs the file into its own data directory at mode `0600`, so later
157
157
  logins need no flag.
158
158
 
159
+ A file that came out of `~/Downloads` or `~/Desktop` is **moved** rather than
160
+ copied — the browser wrote a working OAuth client into a directory that syncs,
161
+ backs up, and gets shared out of, and gmcli will not leave a second copy of it
162
+ there. The destination is written first and the original removed only once that
163
+ succeeded, so there is always at least one readable copy. Pass
164
+ `--keep-download` to leave the file where it is; a client anywhere else is
165
+ copied and never touched.
166
+
159
167
  </details>
160
168
 
161
169
  Check it worked:
@@ -531,12 +539,17 @@ Exit codes:
531
539
 
532
540
  ```console
533
541
  $ gmail auth setup # once, first account
534
- $ gmail auth login # repeat per account
542
+ $ gmail login # repeat per account
535
543
  $ gmail auth list
536
544
  $ gmail auth switch work@example.com # set the default
537
545
  $ gmail --account personal@gmail.com ls # override per command
546
+ $ gmail logout # forget this machine's copy
547
+ $ gmail logout --all # every account
538
548
  ```
539
549
 
550
+ `gmail login` and `gmail logout` are the same commands as `gmail auth login`
551
+ and `gmail auth logout`, spelled where you would first look for them.
552
+
540
553
  ## Configuration
541
554
 
542
555
  `~/.config/gmcli/config.toml` (XDG-aware):
@@ -1,5 +1,5 @@
1
1
  """gmcli — manage Gmail from the terminal."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -14,10 +14,16 @@ from __future__ import annotations
14
14
 
15
15
  import json
16
16
  import time
17
+ from dataclasses import dataclass
17
18
  from pathlib import Path
18
19
  from typing import Any
19
20
 
20
- from ..config import client_secret_path, write_secret_file
21
+ from ..config import (
22
+ DOWNLOAD_DIRS,
23
+ client_secret_path,
24
+ is_in_download_dir,
25
+ write_secret_file,
26
+ )
21
27
  from ..errors import AuthError
22
28
  from .client_config import ClientConfig, parse_client_file, resolve_client
23
29
  from .store import TokenStore, get_store, register_account
@@ -37,12 +43,37 @@ PUBLISHING_STATUS_HINT = (
37
43
  )
38
44
 
39
45
 
40
- def install_client_secret(source: Path) -> Path:
41
- """Copy the user's OAuth client into our data dir so later logins are flagless.
46
+ @dataclass(frozen=True)
47
+ class Installed:
48
+ """Where the client went, and what happened to the file it came from."""
49
+
50
+ path: Path
51
+ source: Path
52
+ moved: bool = False
53
+ #: Set when the source was in a download directory but could not be removed.
54
+ left_behind: str | None = None
55
+
56
+ def __fspath__(self) -> str:
57
+ """So an ``Installed`` is still usable anywhere the path was."""
58
+ return str(self.path)
59
+
60
+
61
+ def install_client_secret(source: Path, *, keep_source: bool = False) -> Installed:
62
+ """Install the user's OAuth client into our data dir, at ``0600``.
42
63
 
43
64
  Validation goes through the same parser the resolver uses, so a web client
44
65
  or a service-account key is rejected here with the same explanation rather
45
66
  than failing later inside the consent flow.
67
+
68
+ A client that came out of a download directory is *moved* rather than
69
+ copied: the browser wrote it world-readable into a directory that syncs,
70
+ backs up, and gets shared out of, and a copy left there is a live
71
+ credential nobody is looking after. It is safe to lose because it is not
72
+ lost — the destination is written and fsynced-into-place first, and the
73
+ source is only unlinked once that has succeeded, so a failure at any point
74
+ leaves at least one readable copy. Anywhere else the file is left exactly
75
+ where it is: a path the user typed or keeps in a repo is a path they
76
+ chose, and deleting it would be a surprise rather than a tidy-up.
46
77
  """
47
78
  source = source.expanduser()
48
79
  if not source.exists():
@@ -59,7 +90,80 @@ def install_client_secret(source: Path) -> Path:
59
90
 
60
91
  dest = client_secret_path()
61
92
  write_secret_file(dest, json.dumps(payload, indent=2))
62
- return dest
93
+
94
+ if keep_source or not is_in_download_dir(source) or source.resolve() == dest:
95
+ return Installed(path=dest, source=source)
96
+ try:
97
+ source.unlink()
98
+ except OSError as exc:
99
+ # Not fatal: the client is installed and works. Say so, because the
100
+ # whole point was that the copy in Downloads should stop existing.
101
+ return Installed(path=dest, source=source, left_behind=str(exc))
102
+ return Installed(path=dest, source=source, moved=True)
103
+
104
+
105
+ def install_notes(installed: "Installed", client_id: str) -> list[tuple[str, str]]:
106
+ """What a front end should say after an install, as ``(level, message)``.
107
+
108
+ The text lives here rather than in the commands because both ``auth login``
109
+ and ``auth setup`` install a client and must say the same thing about it;
110
+ the levels keep ``output.py`` in charge of how it looks.
111
+ """
112
+ notes: list[tuple[str, str]] = []
113
+ if installed.moved:
114
+ notes.append(
115
+ (
116
+ "info",
117
+ f"Moved {installed.source} out of your downloads — "
118
+ f"it now lives only at {installed.path} (0600).",
119
+ )
120
+ )
121
+ elif installed.left_behind:
122
+ notes.append(
123
+ (
124
+ "warn",
125
+ f"Could not remove {installed.source} ({installed.left_behind}). "
126
+ "It is a working OAuth client — delete it yourself.",
127
+ )
128
+ )
129
+
130
+ leftovers = [
131
+ p for p in other_downloaded_copies(client_id) if p != installed.source
132
+ ]
133
+ if leftovers:
134
+ listed = ", ".join(str(p) for p in leftovers)
135
+ notes.append(
136
+ (
137
+ "warn",
138
+ f"Another copy of this same client is still in your downloads: "
139
+ f"{listed}. gmcli will not delete a file it was not given.",
140
+ )
141
+ )
142
+ return notes
143
+
144
+
145
+ def other_downloaded_copies(client_id: str) -> list[Path]:
146
+ """Client files in the download directories carrying this same client id.
147
+
148
+ Re-downloading gives you ``client_secret_… (1).json`` next to the original,
149
+ and moving one of them out solves nothing while its twin is still there.
150
+ These are only reported, never removed: gmcli takes responsibility for the
151
+ file it was handed, not for tidying a directory it does not own.
152
+ """
153
+ found: list[Path] = []
154
+ for name in DOWNLOAD_DIRS:
155
+ base = Path(name).expanduser()
156
+ if not base.is_dir():
157
+ continue
158
+ for path in sorted(base.glob("client_secret*.json")):
159
+ try:
160
+ payload = json.loads(path.read_text(encoding="utf-8"))
161
+ except (OSError, json.JSONDecodeError):
162
+ continue
163
+ block = payload.get("installed")
164
+ if isinstance(block, dict) and block.get("client_id") == client_id:
165
+ found.append(path)
166
+ return found
63
167
 
64
168
 
65
169
  def _serialize(creds: Any, account: str) -> dict[str, Any]:
@@ -226,10 +330,13 @@ __all__ = [
226
330
  "PUBLISHING_STATUS_HINT",
227
331
  "SCOPES",
228
332
  "TESTING_TOKEN_LIFETIME_DAYS",
333
+ "Installed",
229
334
  "fetch_account_email",
230
335
  "install_client_secret",
336
+ "install_notes",
231
337
  "load_credentials",
232
338
  "login",
233
339
  "logout",
340
+ "other_downloaded_copies",
234
341
  "token_age_days",
235
342
  ]
@@ -19,7 +19,7 @@ from .commands import listing, modify, read, send
19
19
  from .commands import ui_cmd
20
20
  from .config import Config
21
21
  from .context import AppContext
22
- from .errors import GmcliError
22
+ from .errors import GmcliError, UsageError
23
23
 
24
24
  app = typer.Typer(
25
25
  name="gmail",
@@ -45,6 +45,7 @@ app.add_typer(draft_cmd.app, name="draft")
45
45
  app.add_typer(attachments_cmd.app, name="attachments")
46
46
  app.add_typer(cache_cmd.app, name="cache")
47
47
 
48
+ auth_cmd.register(app)
48
49
  listing.register(app)
49
50
  read.register(app)
50
51
  modify.register(app)
@@ -105,6 +106,50 @@ def _version_callback(value: bool) -> None:
105
106
  raise typer.Exit()
106
107
 
107
108
 
109
+ def _account_callback(value: str | None) -> str | None:
110
+ """Reject an ``--account`` value that cannot be an address.
111
+
112
+ ``gmail --account -A`` is the shape that motivates this: the parser hands
113
+ the option whatever token comes next, so a mistyped flag became the
114
+ mailbox name and the mistake surfaced much later — as a missing-credential
115
+ error naming ``-A``, or not at all on a command that never opens the
116
+ account. An option callback runs outside the wrappers installed below, so
117
+ it maps itself onto the documented usage exit code.
118
+ """
119
+ if value is None:
120
+ return None
121
+ if "@" not in value:
122
+ exc = UsageError(
123
+ f"--account expects an email address, not {value!r}.",
124
+ hint="`gmail auth list` shows the accounts you are logged in to.",
125
+ )
126
+ _report(exc)
127
+ raise typer.Exit(code=exc.exit_code)
128
+ return value
129
+
130
+
131
+ def _no_command(ctx: typer.Context, json_mode: bool) -> None:
132
+ """Handle global options given with no command to run.
133
+
134
+ ``gmail --json`` used to stop at a bare "Missing command.", which names
135
+ the problem and nothing else. Bare ``gmail`` already answers it by
136
+ printing the help, so do the same here — the flags are the front half of a
137
+ command the user has not finished typing. Under ``--json`` the help would
138
+ land on the pipe, so that case gets the error on stderr instead.
139
+ """
140
+ if json_mode:
141
+ _report(
142
+ UsageError(
143
+ "No command given.",
144
+ hint="Run `gmail --help` to see the commands.",
145
+ )
146
+ )
147
+ else:
148
+ # Typer renders the help itself and returns an empty string.
149
+ ctx.get_help()
150
+ raise typer.Exit(code=2)
151
+
152
+
108
153
  def _upgrade_callback(value: bool) -> None:
109
154
  """``gmail --upgrade``: fetch the current release and install it.
110
155
 
@@ -151,7 +196,7 @@ def _announce_update(ctx: typer.Context) -> None:
151
196
  update.start_check(app_ctx.config)
152
197
 
153
198
 
154
- @app.callback()
199
+ @app.callback(invoke_without_command=True)
155
200
  def main_callback(
156
201
  ctx: typer.Context,
157
202
  account: str = typer.Option(
@@ -160,6 +205,7 @@ def main_callback(
160
205
  "-A",
161
206
  help="Act on this account instead of the default.",
162
207
  metavar="EMAIL",
208
+ callback=_account_callback,
163
209
  ),
164
210
  json_output: bool = typer.Option(
165
211
  False,
@@ -189,6 +235,9 @@ def main_callback(
189
235
  ),
190
236
  ) -> None:
191
237
  """Manage Gmail from the terminal."""
238
+ if ctx.invoked_subcommand is None:
239
+ _no_command(ctx, json_output)
240
+
192
241
  config = Config.load()
193
242
  ctx.obj = AppContext(
194
243
  account_override=account,
@@ -1,4 +1,10 @@
1
- """``gmail auth …`` — login, logout, account switching, and diagnostics."""
1
+ """``gmail auth …`` — login, logout, account switching, and diagnostics.
2
+
3
+ ``gmail login`` and ``gmail logout`` are the same two commands attached to the
4
+ root app as well, by :func:`register`: getting in and out is the pair of verbs
5
+ a user reaches for before they know the group exists, and it costs nothing to
6
+ answer where they look for it.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
@@ -11,6 +17,8 @@ from ..auth.flow import (
11
17
  PUBLISHING_STATUS_HINT,
12
18
  SCOPES,
13
19
  TESTING_TOKEN_LIFETIME_DAYS,
20
+ install_client_secret,
21
+ install_notes,
14
22
  load_credentials,
15
23
  login,
16
24
  logout,
@@ -54,6 +62,12 @@ def login_cmd(
54
62
  help="Fix the loopback redirect port (useful over an SSH tunnel). "
55
63
  "0 picks a free one.",
56
64
  ),
65
+ keep_download: bool = typer.Option(
66
+ False,
67
+ "--keep-download",
68
+ help="Leave the credentials file where it is. By default a client "
69
+ "found in ~/Downloads or ~/Desktop is moved, not copied.",
70
+ ),
57
71
  ) -> None:
58
72
  """Authorize an account through your browser."""
59
73
  app_ctx: AppContext = ctx.obj
@@ -63,9 +77,15 @@ def login_cmd(
63
77
  # like a crash rather than a missing prerequisite.
64
78
  resolve_client(credentials, config=app_ctx.config)
65
79
 
80
+ # Install here rather than inside `login`, so the file is safe before the
81
+ # browser opens and this command has something to report about it.
82
+ installed = None
83
+ if credentials is not None:
84
+ installed = install_client_secret(credentials, keep_source=keep_download)
85
+
66
86
  out.info("Opening your browser to authorize gmcli…")
67
87
  out.info(f"[dim]Requesting scope: {SCOPES[0]}[/dim]")
68
- email, store, client = login(credentials=credentials, port=port)
88
+ email, store, client = login(port=port)
69
89
 
70
90
  if app_ctx.json_mode:
71
91
  out.json(
@@ -80,6 +100,9 @@ def login_cmd(
80
100
 
81
101
  out.success(f"Authorized as [bold]{email}[/bold]")
82
102
  out.info(f"[dim]Credentials stored in {store.description}[/dim]")
103
+ if installed is not None:
104
+ for level, message in install_notes(installed, client.client_id):
105
+ (out.warn if level == "warn" else out.info)(message)
83
106
 
84
107
  if not app_ctx.config.default_account:
85
108
  app_ctx.config.default_account = email
@@ -400,6 +423,25 @@ def _clock_skew_seconds() -> float | None:
400
423
  return None
401
424
 
402
425
 
426
+ def register(root: typer.Typer) -> None:
427
+ """Attach ``gmail login`` / ``gmail logout`` to the root app.
428
+
429
+ The same callbacks, not copies, so the two spellings can never drift in
430
+ flags or behaviour — each registration carries its own error-handling
431
+ wrapper, which is all ``cli.py`` needs.
432
+ """
433
+ root.command(
434
+ "login",
435
+ help="Authorize an account through your browser. "
436
+ "Same as [bold]gmail auth login[/bold].",
437
+ )(login_cmd)
438
+ root.command(
439
+ "logout",
440
+ help="Remove stored credentials from this machine. "
441
+ "Same as [bold]gmail auth logout[/bold].",
442
+ )(logout_cmd)
443
+
444
+
403
445
  # Registered here rather than in cli.py so the wizard travels with the group it
404
446
  # belongs to. setup.py imports nothing from this module, so there is no cycle.
405
447
  from .setup import register as _register_setup # noqa: E402
@@ -25,16 +25,19 @@ from pathlib import Path
25
25
  import typer
26
26
 
27
27
  from ..auth.client_config import load_client_file
28
- from ..auth.flow import install_client_secret, login
29
- from ..config import client_secret_path
28
+ from ..auth.flow import install_client_secret, install_notes, login
29
+ from ..config import DOWNLOAD_DIRS as BROWSER_DOWNLOAD_DIRS
30
+ from ..config import client_secret_path, is_in_download_dir
30
31
  from ..context import AppContext
31
32
  from ..errors import AuthError, UsageError
32
33
  from ..output import Renderer
33
34
 
34
35
  CONSOLE = "https://console.cloud.google.com"
35
36
 
36
- # Where browsers land a downloaded client, newest first wins.
37
- DOWNLOAD_DIRS = ("~/Downloads", "~/Desktop", ".")
37
+ # Where browsers land a downloaded client, newest first wins. The current
38
+ # directory is searched but is not one of ``config.DOWNLOAD_DIRS``: a file
39
+ # someone ran the command next to is not in transit, so it is never moved.
40
+ DOWNLOAD_DIRS = (*BROWSER_DOWNLOAD_DIRS, ".")
38
41
  DOWNLOAD_GLOBS = ("client_secret*.json", "client_secret*.JSON")
39
42
  # A file touched within this window is almost certainly the one just downloaded.
40
43
  FRESH_SECONDS = 1800
@@ -129,6 +132,12 @@ def _collect_client_file(out: Renderer) -> Path:
129
132
  if candidates:
130
133
  best = candidates[0]
131
134
  out.info(f"\n Found [green]{best}[/green]")
135
+ if is_in_download_dir(best):
136
+ out.info(
137
+ " [dim]It will be moved into gmcli's data directory, not "
138
+ "copied — an OAuth client should not be left in a download "
139
+ "folder. Re-run with --keep-download to leave it.[/dim]"
140
+ )
132
141
  if typer.confirm(" Use this file?", default=True):
133
142
  return best
134
143
 
@@ -179,6 +188,12 @@ def register(auth_app: typer.Typer) -> None:
179
188
  force: bool = typer.Option(
180
189
  False, "--force", "-f", help="Replace an already-installed client."
181
190
  ),
191
+ keep_download: bool = typer.Option(
192
+ False,
193
+ "--keep-download",
194
+ help="Leave the downloaded client JSON in place. By default it is "
195
+ "moved out of ~/Downloads rather than copied.",
196
+ ),
182
197
  ) -> None:
183
198
  """Set up Google sign-in, step by step.
184
199
 
@@ -191,8 +206,9 @@ def register(auth_app: typer.Typer) -> None:
191
206
 
192
207
  # The fast path: a file was handed to us, so no walkthrough is needed.
193
208
  if credentials is not None:
194
- install_client_secret(credentials)
209
+ installed = install_client_secret(credentials, keep_source=keep_download)
195
210
  out.success(f"Installed OAuth client from {credentials}")
211
+ _report_install(out, installed)
196
212
  _finish_with_login(app_ctx, no_browser=no_browser)
197
213
  return
198
214
 
@@ -226,9 +242,10 @@ def register(auth_app: typer.Typer) -> None:
226
242
  _step_create_client(out, project, total, no_browser)
227
243
 
228
244
  path = _collect_client_file(out)
229
- install_client_secret(path)
245
+ installed = install_client_secret(path, keep_source=keep_download)
230
246
  client = load_client_file(client_secret_path())
231
247
  out.success(f"OAuth client installed ({client.client_id[:24]}…)")
248
+ _report_install(out, installed, client_id=client.client_id)
232
249
 
233
250
  _finish_with_login(app_ctx, no_browser=no_browser)
234
251
 
@@ -237,6 +254,18 @@ def register(auth_app: typer.Typer) -> None:
237
254
  auth_app.registered_commands.insert(0, auth_app.registered_commands.pop())
238
255
 
239
256
 
257
+ def _report_install(out: Renderer, installed, client_id: str | None = None) -> None:
258
+ """Say what became of the file the client came from.
259
+
260
+ The user handed a path to a file they can see; if gmcli deleted it, that
261
+ has to be stated rather than discovered.
262
+ """
263
+ if client_id is None:
264
+ client_id = load_client_file(client_secret_path()).client_id
265
+ for level, message in install_notes(installed, client_id):
266
+ (out.warn if level == "warn" else out.info)(message)
267
+
268
+
240
269
  def _intro(out: Renderer) -> None:
241
270
  out.info(
242
271
  "\n[bold]Setting up Google sign-in for gmcli[/bold]\n\n"
@@ -30,6 +30,31 @@ SECRET_MODE = 0o600
30
30
  SECRET_DIR_MODE = 0o700
31
31
 
32
32
 
33
+ # Where a browser lands a file. These are transit, not storage: nothing in
34
+ # them is where the user meant to keep it, and a directory every download in
35
+ # the world passes through is the wrong home for an OAuth client. Anything
36
+ # gmcli installs from here it takes *out* of here — see
37
+ # ``auth/flow.py:install_client_secret``. The current directory is
38
+ # deliberately absent: setup looks there for a downloaded client, but a file
39
+ # someone chose to run the command next to is not in transit.
40
+ DOWNLOAD_DIRS = ("~/Downloads", "~/Desktop")
41
+
42
+
43
+ def is_in_download_dir(path: Path) -> bool:
44
+ """Whether ``path`` sits directly in a browser's download directory."""
45
+ try:
46
+ parent = path.expanduser().resolve().parent
47
+ except OSError: # pragma: no cover - unresolvable path
48
+ return False
49
+ for name in DOWNLOAD_DIRS:
50
+ try:
51
+ if parent == Path(name).expanduser().resolve():
52
+ return True
53
+ except OSError: # pragma: no cover - unresolvable path
54
+ continue
55
+ return False
56
+
57
+
33
58
  def config_dir() -> Path:
34
59
  return Path(platformdirs.user_config_dir(APP_NAME))
35
60
 
@@ -427,8 +427,18 @@ class MailApp:
427
427
  if outcome == "cancel":
428
428
  self.state.note("Cancelled")
429
429
  return
430
- if handler is not None:
430
+ if handler is None:
431
+ return
432
+ try:
431
433
  handler(editor.text.strip())
434
+ except GmcliError as exc:
435
+ # Same contract as ``busy()``: a prompt handler runs on the key
436
+ # that submitted it, so anything escaping here would take the
437
+ # session down over one mistyped line.
438
+ detail = f"{exc.message} {exc.hint or ''}".strip()
439
+ self.state.note(detail, render.THEME["error"])
440
+ except Exception as exc: # noqa: BLE001 — a UI must not die on one bad line
441
+ self.state.note(f"{type(exc).__name__}: {exc}", render.THEME["error"])
432
442
 
433
443
  def confirm(self, question: str, action: Callable[[], None]) -> None:
434
444
  """Ask for a y/n on the status line before doing something."""
@@ -1011,6 +1021,57 @@ class MailApp:
1011
1021
  self.state.note(exc.message, render.THEME["warn"])
1012
1022
  return body
1013
1023
 
1024
+ def _ask_recipients(
1025
+ self,
1026
+ label: str,
1027
+ then: Callable[[list[str]], None],
1028
+ *,
1029
+ initial: str = "",
1030
+ empty_note: str,
1031
+ retry: bool = False,
1032
+ ) -> None:
1033
+ """Prompt for addresses, and ask again if what came back is not one.
1034
+
1035
+ A typo in a footer prompt is the most ordinary thing a user can do, so
1036
+ it must cost them the typo and nothing else. ``validate_addresses``
1037
+ raises, and a prompt handler runs on the key that submitted it — so
1038
+ letting that escape would end the session and lose the draft. The
1039
+ complaint goes in the *label*: while a prompt is open it is what the
1040
+ footer shows, and the status line is not on screen to read.
1041
+ """
1042
+
1043
+ def handler(text: str) -> None:
1044
+ if not text:
1045
+ # An empty line is how you back out, and the one case that is
1046
+ # not a typo to correct.
1047
+ self.state.note(empty_note, render.THEME["warn"])
1048
+ return
1049
+ try:
1050
+ recipients = compose.validate_addresses(
1051
+ compose.split_addresses([text]), field="to"
1052
+ )
1053
+ except GmcliError as exc:
1054
+ self.state.note(exc.message, render.THEME["error"])
1055
+ recipients = []
1056
+ if not recipients:
1057
+ # ``split_addresses`` drops some malformed input outright
1058
+ # rather than raising — ``bob@`` parses to nothing at all —
1059
+ # so an empty result off a non-empty line is a typo too.
1060
+ self._ask_recipients(
1061
+ label,
1062
+ then,
1063
+ initial=text,
1064
+ empty_note=empty_note,
1065
+ retry=True,
1066
+ )
1067
+ return
1068
+ then(recipients)
1069
+
1070
+ # The complaint prefixes the label rather than replacing it, and the
1071
+ # unprefixed label is what recurses — two typos in a row must not read
1072
+ # "not an address — not an address — to: ".
1073
+ self.ask(f"not an address — {label}" if retry else label, handler, initial)
1074
+
1014
1075
  def _send(self, message, *, thread_id: str | None, verb: str) -> None:
1015
1076
  summary = compose.describe(message)
1016
1077
 
@@ -1052,13 +1113,7 @@ class MailApp:
1052
1113
  if parent is None:
1053
1114
  return
1054
1115
 
1055
- def with_recipients(text: str) -> None:
1056
- recipients = compose.validate_addresses(
1057
- compose.split_addresses([text]), field="to"
1058
- )
1059
- if not recipients:
1060
- self.state.note("No recipients — nothing forwarded", render.THEME["warn"])
1061
- return
1116
+ def with_recipients(recipients: list[str]) -> None:
1062
1117
  body = self._edit(_header_stub(recipients, [],
1063
1118
  compose.forward_subject(parent.subject)))
1064
1119
  with self.busy("Building forward"):
@@ -1067,17 +1122,14 @@ class MailApp:
1067
1122
  )
1068
1123
  self._send(message, thread_id=None, verb="Forwarded")
1069
1124
 
1070
- self.ask("forward to: ", with_recipients)
1125
+ self._ask_recipients(
1126
+ "forward to: ",
1127
+ with_recipients,
1128
+ empty_note="No recipients — nothing forwarded",
1129
+ )
1071
1130
 
1072
1131
  def compose_new(self) -> None:
1073
- def with_recipients(text: str) -> None:
1074
- recipients = compose.validate_addresses(
1075
- compose.split_addresses([text]), field="to"
1076
- )
1077
- if not recipients:
1078
- self.state.note("No recipients — nothing sent", render.THEME["warn"])
1079
- return
1080
-
1132
+ def with_recipients(recipients: list[str]) -> None:
1081
1133
  def with_subject(subject: str) -> None:
1082
1134
  body = self._edit(_header_stub(recipients, [], subject))
1083
1135
  if not body:
@@ -1096,7 +1148,9 @@ class MailApp:
1096
1148
 
1097
1149
  self.ask("subject: ", with_subject)
1098
1150
 
1099
- self.ask("to: ", with_recipients)
1151
+ self._ask_recipients(
1152
+ "to: ", with_recipients, empty_note="No recipients — nothing sent"
1153
+ )
1100
1154
 
1101
1155
 
1102
1156
  def _select_attachments(
@@ -203,4 +203,9 @@ def isolated_dirs(tmp_path, monkeypatch):
203
203
  monkeypatch.setenv(var, str(target))
204
204
  # Never let a test reach the developer's real keyring.
205
205
  monkeypatch.setenv("GMCLI_TOKEN_STORE", "file")
206
+ # …nor their real home. `~/Downloads` is a path gmcli both reads and
207
+ # *deletes from* (see `auth/flow.py:install_client_secret`), and it does
208
+ # not come from XDG, so redirecting HOME is the only thing between the
209
+ # suite and a developer's actual download folder.
210
+ monkeypatch.setenv("HOME", str(tmp_path))
206
211
  return tmp_path
@@ -8,7 +8,7 @@ import time
8
8
  import pytest
9
9
 
10
10
  from gmcli.auth import client_config as cc
11
- from gmcli.config import Config, client_secret_path
11
+ from gmcli.config import Config, client_secret_path, is_in_download_dir
12
12
  from gmcli.errors import AuthError
13
13
 
14
14
 
@@ -233,3 +233,126 @@ def test_unrelated_files_are_not_picked_up(tmp_path, monkeypatch):
233
233
 
234
234
  monkeypatch.setattr(setup, "DOWNLOAD_DIRS", (str(downloads),))
235
235
  assert setup.find_downloaded_client() == []
236
+
237
+
238
+ # -- a downloaded client is moved, not copied --------------------------------
239
+
240
+
241
+ def downloaded(tmp_path, payload: dict | None = None, name: str = "client_secret_1.json"):
242
+ """A client JSON sitting in ``~/Downloads``, as a browser would leave it."""
243
+ downloads = tmp_path / "Downloads"
244
+ downloads.mkdir(exist_ok=True)
245
+ path = downloads / name
246
+ path.write_text(json.dumps(payload or desktop_payload()), encoding="utf-8")
247
+ return path
248
+
249
+
250
+ def test_a_client_in_downloads_is_moved_out_of_it(isolated_dirs):
251
+ from gmcli.auth import flow
252
+
253
+ source = downloaded(isolated_dirs)
254
+ result = flow.install_client_secret(source)
255
+
256
+ assert result.moved is True
257
+ assert not source.exists() # the browser's copy is gone
258
+ assert client_secret_path().exists()
259
+ assert cc.load_client_file(client_secret_path()).client_secret == "shh"
260
+
261
+
262
+ def test_the_installed_copy_is_private(isolated_dirs):
263
+ from gmcli.auth import flow
264
+
265
+ flow.install_client_secret(downloaded(isolated_dirs))
266
+ assert client_secret_path().stat().st_mode & 0o777 == 0o600
267
+
268
+
269
+ def test_a_client_kept_elsewhere_is_only_copied(isolated_dirs, tmp_path):
270
+ """A path someone chose is a path they meant; deleting it is a surprise."""
271
+ from gmcli.auth import flow
272
+
273
+ kept = tmp_path / "dotfiles" / "client.json"
274
+ kept.parent.mkdir()
275
+ kept.write_text(json.dumps(desktop_payload()), encoding="utf-8")
276
+
277
+ result = flow.install_client_secret(kept)
278
+ assert result.moved is False
279
+ assert kept.exists()
280
+ assert client_secret_path().exists()
281
+
282
+
283
+ def test_keep_source_leaves_the_download_alone(isolated_dirs):
284
+ from gmcli.auth import flow
285
+
286
+ source = downloaded(isolated_dirs)
287
+ result = flow.install_client_secret(source, keep_source=True)
288
+ assert result.moved is False
289
+ assert source.exists()
290
+ assert client_secret_path().exists()
291
+
292
+
293
+ def test_an_invalid_client_is_rejected_before_anything_is_deleted(isolated_dirs):
294
+ """Nothing is removed on a path that never installed anything."""
295
+ from gmcli.auth import flow
296
+
297
+ source = downloaded(isolated_dirs, {"web": {"client_id": "x"}})
298
+ with pytest.raises(AuthError):
299
+ flow.install_client_secret(source)
300
+ assert source.exists()
301
+ assert not client_secret_path().exists()
302
+
303
+
304
+ def test_an_unremovable_download_is_reported_not_swallowed(isolated_dirs, monkeypatch):
305
+ """The client still installs; the point is that the user is told."""
306
+ from gmcli.auth import flow
307
+
308
+ source = downloaded(isolated_dirs)
309
+
310
+ def refuse(self):
311
+ raise OSError("Permission denied")
312
+
313
+ monkeypatch.setattr("pathlib.Path.unlink", refuse)
314
+ result = flow.install_client_secret(source)
315
+
316
+ assert result.moved is False
317
+ assert result.left_behind == "Permission denied"
318
+ assert client_secret_path().exists()
319
+ levels = [level for level, _ in flow.install_notes(result, "111-abc")]
320
+ assert "warn" in levels
321
+
322
+
323
+ def test_a_second_copy_of_the_same_client_is_reported(isolated_dirs):
324
+ """Browsers name a re-download `… (1).json`, and one left behind is the
325
+ same live credential the move was meant to get rid of."""
326
+ from gmcli.auth import flow
327
+
328
+ twin = downloaded(isolated_dirs, name="client_secret_1 (1).json")
329
+ source = downloaded(isolated_dirs)
330
+
331
+ result = flow.install_client_secret(source)
332
+ notes = flow.install_notes(result, "111-abc.apps.googleusercontent.com")
333
+
334
+ assert twin.exists() # reported, never deleted
335
+ assert any(level == "warn" and str(twin) in text for level, text in notes)
336
+
337
+
338
+ def test_a_different_client_in_downloads_is_not_mentioned(isolated_dirs):
339
+ from gmcli.auth import flow
340
+
341
+ other = downloaded(
342
+ isolated_dirs,
343
+ desktop_payload("999-zzz.apps.googleusercontent.com"),
344
+ name="client_secret_other.json",
345
+ )
346
+ result = flow.install_client_secret(downloaded(isolated_dirs))
347
+ notes = flow.install_notes(result, "111-abc.apps.googleusercontent.com")
348
+
349
+ assert other.exists()
350
+ assert not any(str(other) in text for _, text in notes)
351
+
352
+
353
+ def test_the_current_directory_is_not_a_download_folder(isolated_dirs, tmp_path):
354
+ """`.` is searched for a client but never emptied of one."""
355
+ from gmcli.commands import setup
356
+
357
+ assert "." in setup.DOWNLOAD_DIRS
358
+ assert not is_in_download_dir(tmp_path / "here" / "client_secret.json")
@@ -566,6 +566,35 @@ def test_version():
566
566
  assert "gmcli" in result.output
567
567
 
568
568
 
569
+ def test_global_options_with_no_command_show_the_help(env):
570
+ """A bare "Missing command." names the problem and nothing else."""
571
+ result = invoke("--quiet")
572
+ assert result.exit_code == 2
573
+ assert "Usage:" in result.output
574
+ assert "login" in result.output
575
+
576
+
577
+ def test_no_command_under_json_keeps_stdout_empty(env):
578
+ """The help would land on the pipe, so the error goes to stderr instead."""
579
+ result = invoke("--json")
580
+ assert result.exit_code == 2
581
+ assert result.stdout == ""
582
+ assert "No command given" in result.output
583
+
584
+
585
+ def test_a_mistyped_flag_is_not_taken_as_an_account(env):
586
+ """`--account -A` used to make "-A" the mailbox and fail much later."""
587
+ result = invoke("--account", "-A", "ls")
588
+ assert result.exit_code == 2
589
+ assert "email address" in result.output
590
+ assert not env.calls
591
+
592
+
593
+ def test_account_still_accepts_an_address(env):
594
+ seed_threads(env)
595
+ assert invoke("--account", ACCOUNT, "ls").exit_code == 0
596
+
597
+
569
598
  def test_cache_clear(env):
570
599
  seed_threads(env)
571
600
  invoke("ls")
@@ -617,6 +646,86 @@ def fake_login(monkeypatch):
617
646
  return calls
618
647
 
619
648
 
649
+ @pytest.fixture
650
+ def fake_auth_login(monkeypatch):
651
+ """Stub the consent flow for `auth login`, as ``fake_login`` does for setup."""
652
+ from gmcli.auth.client_config import ClientConfig
653
+ from gmcli.auth.store import FileStore
654
+ from gmcli.commands import auth as auth_mod
655
+
656
+ calls = []
657
+
658
+ def _login(**kwargs):
659
+ calls.append(kwargs)
660
+ store = FileStore()
661
+ store.save(ACCOUNT, {"token": "t", "refresh_token": "r"})
662
+ return ACCOUNT, store, ClientConfig(
663
+ "999-xyz.apps.googleusercontent.com", "shh", "installed client"
664
+ )
665
+
666
+ monkeypatch.setattr(auth_mod, "login", _login)
667
+ return calls
668
+
669
+
670
+ def test_login_moves_a_downloaded_client_out_of_downloads(
671
+ isolated_dirs, tmp_path, fake_auth_login
672
+ ):
673
+ from gmcli.config import client_secret_path
674
+
675
+ downloads = tmp_path / "Downloads"
676
+ downloads.mkdir()
677
+ path = desktop_client_file(downloads)
678
+
679
+ result = invoke("auth", "login", "--credentials", str(path))
680
+
681
+ assert result.exit_code == 0, result.output
682
+ assert not path.exists()
683
+ assert client_secret_path().exists()
684
+ assert "Moved" in result.output
685
+
686
+
687
+ def test_login_can_be_told_to_leave_the_download(
688
+ isolated_dirs, tmp_path, fake_auth_login
689
+ ):
690
+ downloads = tmp_path / "Downloads"
691
+ downloads.mkdir()
692
+ path = desktop_client_file(downloads)
693
+
694
+ invoke("auth", "login", "--credentials", str(path), "--keep-download")
695
+ assert path.exists()
696
+
697
+
698
+ def test_logout_is_reachable_without_the_auth_group(env):
699
+ from gmcli.auth.store import list_accounts
700
+
701
+ result = invoke("logout")
702
+ assert result.exit_code == 0, result.output
703
+ assert list_accounts() == []
704
+
705
+
706
+ def test_login_is_reachable_without_the_auth_group(
707
+ isolated_dirs, tmp_path, fake_auth_login
708
+ ):
709
+ path = desktop_client_file(tmp_path)
710
+ result = invoke("login", "--credentials", str(path))
711
+
712
+ assert result.exit_code == 0, result.output
713
+ assert fake_auth_login, "the consent flow was never reached"
714
+ assert ACCOUNT in result.output
715
+
716
+
717
+ def test_the_two_spellings_of_login_and_logout_cannot_drift():
718
+ """`gmail login` is the same command object as `gmail auth login`."""
719
+ import typer.main
720
+
721
+ root = typer.main.get_command(app)
722
+ auth_group = root.get_command(None, "auth")
723
+ for name in ("login", "logout"):
724
+ top = root.get_command(None, name)
725
+ grouped = auth_group.get_command(None, name)
726
+ assert [p.opts for p in top.params] == [p.opts for p in grouped.params]
727
+
728
+
620
729
  def test_setup_is_registered(env):
621
730
  result = invoke("auth", "setup", "--help")
622
731
  assert result.exit_code == 0
@@ -647,6 +756,41 @@ def test_setup_installs_the_client_for_later_logins(
647
756
  assert resolve_client().client_id == "999-xyz.apps.googleusercontent.com"
648
757
 
649
758
 
759
+ def test_setup_moves_a_downloaded_client_out_of_downloads(
760
+ isolated_dirs, tmp_path, fake_login
761
+ ):
762
+ """The whole point: after setup, no OAuth client is left in ~/Downloads."""
763
+ from gmcli.config import client_secret_path
764
+
765
+ downloads = tmp_path / "Downloads"
766
+ downloads.mkdir()
767
+ path = desktop_client_file(downloads)
768
+
769
+ result = invoke("auth", "setup", "--credentials", str(path))
770
+
771
+ assert result.exit_code == 0, result.output
772
+ assert not path.exists()
773
+ assert client_secret_path().exists()
774
+ assert "Moved" in result.output
775
+
776
+
777
+ def test_setup_can_be_told_to_leave_the_download(isolated_dirs, tmp_path, fake_login):
778
+ downloads = tmp_path / "Downloads"
779
+ downloads.mkdir()
780
+ path = desktop_client_file(downloads)
781
+
782
+ invoke("auth", "setup", "--credentials", str(path), "--keep-download")
783
+ assert path.exists()
784
+
785
+
786
+ def test_setup_does_not_delete_a_client_kept_elsewhere(
787
+ isolated_dirs, tmp_path, fake_login
788
+ ):
789
+ path = desktop_client_file(tmp_path)
790
+ invoke("auth", "setup", "--credentials", str(path))
791
+ assert path.exists()
792
+
793
+
650
794
  def test_setup_sets_the_default_account(isolated_dirs, tmp_path, fake_login):
651
795
  from gmcli.config import Config
652
796
 
@@ -1526,3 +1526,77 @@ def test_the_reader_surface_reaches_the_right_hand_edge(ctx):
1526
1526
  press(ui, "enter")
1527
1527
  for line in render.reader(ui.state, 100, 25):
1528
1528
  assert str(render.exact(line, 100).style) == render.THEME["page"]
1529
+
1530
+
1531
+ # -- composing ----------------------------------------------------------------
1532
+
1533
+
1534
+ def test_a_mistyped_address_asks_again_instead_of_ending_the_session(ctx):
1535
+ """The most ordinary mistake there is must cost the typo and nothing else."""
1536
+ ui = build(ctx, [])
1537
+ press(ui, "c")
1538
+ type_in(ui, "snehangshubhuin2018")
1539
+ assert ui.state.quit is False
1540
+ assert ui.state.prompt is not None # still asking, not gone
1541
+ assert "not an address" in ui.state.prompt.label
1542
+ assert "to: " in ui.state.prompt.label
1543
+
1544
+
1545
+ def test_two_typos_in_a_row_do_not_stack_the_complaint(ctx):
1546
+ ui = build(ctx, [])
1547
+ press(ui, "c")
1548
+ type_in(ui, "nonsense")
1549
+ type_in(ui, "still nonsense")
1550
+ assert ui.state.prompt.label == "not an address — to: "
1551
+
1552
+
1553
+ def test_the_rejected_text_comes_back_to_be_edited(ctx):
1554
+ """Re-typing a whole address to fix one missing character is a punishment."""
1555
+ ui = build(ctx, [])
1556
+ press(ui, "c")
1557
+ type_in(ui, "bob@")
1558
+ assert ui.state.prompt.text == "bob@"
1559
+ assert ui.state.prompt.cursor == len("bob@")
1560
+
1561
+
1562
+ def test_a_corrected_address_carries_on_to_the_subject(ctx):
1563
+ ui = build(ctx, [])
1564
+ press(ui, "c")
1565
+ type_in(ui, "nonsense")
1566
+ type_in(ui, "bob@example.com")
1567
+ assert ui.state.prompt is not None
1568
+ assert ui.state.prompt.label == "subject: "
1569
+
1570
+
1571
+ def test_an_empty_recipient_list_abandons_rather_than_re_asking(ctx):
1572
+ """Submitting nothing is how you back out; it is not a typo to correct."""
1573
+ ui = build(ctx, [])
1574
+ press(ui, "c")
1575
+ type_in(ui, "")
1576
+ assert ui.state.prompt is None
1577
+ assert ui.state.status_style == render.THEME["warn"]
1578
+ assert ui.state.quit is False
1579
+
1580
+
1581
+ def test_forwarding_to_a_bad_address_also_asks_again(ctx):
1582
+ ui = build(ctx, [])
1583
+ press(ui, "enter", "f")
1584
+ type_in(ui, "not-an-address")
1585
+ assert ui.state.quit is False
1586
+ assert "forward to: " in ui.state.prompt.label
1587
+
1588
+
1589
+ def test_a_prompt_handler_that_raises_lands_on_the_status_line(ctx):
1590
+ """The same contract as ``busy()``, for the keys that submit a prompt."""
1591
+ from gmcli.errors import UsageError
1592
+
1593
+ ui = build(ctx, [])
1594
+
1595
+ def explode(_text: str) -> None:
1596
+ raise UsageError("nope", hint="try that again")
1597
+
1598
+ ui.ask("thing: ", explode)
1599
+ press(ui, "x", "enter")
1600
+ assert ui.state.quit is False
1601
+ assert ui.state.status_style == render.THEME["error"]
1602
+ assert "nope" in ui.state.status
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes