agy-switch 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.
agy_switch/__init__.py ADDED
@@ -0,0 +1,428 @@
1
+ """agy-switch: switch between Antigravity CLI (agy) accounts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from datetime import datetime, timezone
14
+ from importlib.metadata import PackageNotFoundError, version
15
+ from pathlib import Path
16
+
17
+ try:
18
+ __version__ = version("agy-switch")
19
+ except PackageNotFoundError: # running from a source tree that isn't installed
20
+ __version__ = "0+unknown"
21
+
22
+ # agy keeps its login in the system keyring, and only falls back to this file (in
23
+ # the agy directory) when no keyring is available. Profiles always use this file name.
24
+ TOKEN_FILE = "antigravity-oauth-token"
25
+ KEYRING_ATTRIBUTES = {"service": "gemini", "username": "antigravity"}
26
+ KEYRING_LABEL = "Password for 'antigravity' on 'gemini'"
27
+ NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
28
+
29
+
30
+ class SwitchError(Exception):
31
+ pass
32
+
33
+
34
+ # --------------------------------------------------------------------------- paths
35
+
36
+
37
+ def agy_home() -> Path:
38
+ return Path(
39
+ os.environ.get("AGY_HOME", Path.home() / ".gemini" / "antigravity-cli")
40
+ ).expanduser()
41
+
42
+
43
+ def store_home() -> Path:
44
+ return Path(
45
+ os.environ.get("AGY_SWITCH_HOME", Path.home() / ".agy-switch")
46
+ ).expanduser()
47
+
48
+
49
+ def profiles_dir() -> Path:
50
+ return store_home() / "profiles"
51
+
52
+
53
+ def state_file() -> Path:
54
+ return store_home() / "state.json"
55
+
56
+
57
+ def profile_dir(name: str) -> Path:
58
+ return profiles_dir() / name
59
+
60
+
61
+ # --------------------------------------------------------------------------- helpers
62
+
63
+
64
+ def validate_name(name: str) -> str:
65
+ if not NAME_RE.match(name):
66
+ raise SwitchError(
67
+ f"invalid profile name: {name!r} "
68
+ "(use letters, digits, '.', '_' or '-')"
69
+ )
70
+ return name
71
+
72
+
73
+ def _ensure_private_dir(path: Path) -> None:
74
+ path.mkdir(parents=True, exist_ok=True)
75
+ os.chmod(path, 0o700)
76
+
77
+
78
+ def _atomic_write(dst: Path, data: bytes) -> None:
79
+ """Atomically write data to dst with 0600 permissions."""
80
+ dst.parent.mkdir(parents=True, exist_ok=True)
81
+ fd, tmp = tempfile.mkstemp(dir=dst.parent, prefix=f".{dst.name}.")
82
+ try:
83
+ with os.fdopen(fd, "wb") as out:
84
+ out.write(data)
85
+ os.chmod(tmp, 0o600)
86
+ os.replace(tmp, dst)
87
+ except BaseException:
88
+ Path(tmp).unlink(missing_ok=True)
89
+ raise
90
+
91
+
92
+ def _write_json(path: Path, data: dict) -> None:
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
95
+ with os.fdopen(fd, "w") as f:
96
+ json.dump(data, f, indent=2)
97
+ f.write("\n")
98
+ os.chmod(tmp, 0o600)
99
+ os.replace(tmp, path)
100
+
101
+
102
+ def _read_json(path: Path) -> dict:
103
+ try:
104
+ return json.loads(path.read_text())
105
+ except (FileNotFoundError, json.JSONDecodeError):
106
+ return {}
107
+
108
+
109
+ def _keyring_collection():
110
+ """Return the unlocked default keyring, or None if this system has no keyring."""
111
+ try:
112
+ import secretstorage
113
+ except ImportError:
114
+ return None
115
+ try:
116
+ conn = secretstorage.dbus_init()
117
+ if not secretstorage.check_service_availability(conn):
118
+ return None
119
+ collection = secretstorage.get_default_collection(conn)
120
+ except (secretstorage.exceptions.SecretStorageException, OSError):
121
+ return None
122
+ # A locked keyring must not fall through to the file: agy would ignore it.
123
+ if collection.is_locked() and collection.unlock():
124
+ raise SwitchError("the system keyring is locked; unlock it and try again")
125
+ return collection
126
+
127
+
128
+ def read_live_token() -> bytes | None:
129
+ """Return the current agy login: the keyring if it has one, else the file."""
130
+ collection = _keyring_collection()
131
+ if collection is not None:
132
+ for item in collection.search_items(KEYRING_ATTRIBUTES):
133
+ return item.get_secret()
134
+ try:
135
+ return (agy_home() / TOKEN_FILE).read_bytes()
136
+ except FileNotFoundError:
137
+ return None
138
+
139
+
140
+ def write_live_token(data: bytes) -> None:
141
+ collection = _keyring_collection()
142
+ if collection is None:
143
+ _atomic_write(agy_home() / TOKEN_FILE, data)
144
+ else:
145
+ collection.create_item(KEYRING_LABEL, KEYRING_ATTRIBUTES, data, replace=True)
146
+
147
+
148
+ def clear_live_token() -> None:
149
+ collection = _keyring_collection()
150
+ if collection is not None:
151
+ for item in collection.search_items(KEYRING_ATTRIBUTES):
152
+ item.delete()
153
+ (agy_home() / TOKEN_FILE).unlink(missing_ok=True)
154
+
155
+
156
+ def _email_from_token(data: bytes | None) -> str | None:
157
+ try:
158
+ return json.loads(data or b"{}").get("id_token_email")
159
+ except ValueError:
160
+ return None
161
+
162
+
163
+ def token_email(token_path: Path) -> str | None:
164
+ return _read_json(token_path).get("id_token_email")
165
+
166
+
167
+ def load_state() -> dict:
168
+ return _read_json(state_file())
169
+
170
+
171
+ def save_state(state: dict) -> None:
172
+ _ensure_private_dir(store_home())
173
+ _write_json(state_file(), state)
174
+
175
+
176
+ def active_profile() -> str | None:
177
+ name = load_state().get("active")
178
+ if name and profile_dir(name).is_dir():
179
+ return name
180
+ return None
181
+
182
+
183
+ def list_profiles() -> list[str]:
184
+ if not profiles_dir().is_dir():
185
+ return []
186
+ return sorted(
187
+ p.name for p in profiles_dir().iterdir()
188
+ if p.is_dir() and (p / TOKEN_FILE).is_file()
189
+ )
190
+
191
+
192
+ def profile_email(name: str) -> str | None:
193
+ return token_email(profile_dir(name) / TOKEN_FILE)
194
+
195
+
196
+ def live_email() -> str | None:
197
+ return _email_from_token(read_live_token())
198
+
199
+
200
+ def _now() -> str:
201
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
202
+
203
+
204
+ # --------------------------------------------------------------------------- core
205
+
206
+
207
+ def snapshot_live(name: str) -> None:
208
+ """Copy the current agy credentials into profile `name`."""
209
+ data = read_live_token()
210
+ if data is None:
211
+ raise SwitchError(
212
+ f"no login found in the system keyring or at {agy_home() / TOKEN_FILE}. "
213
+ "Log in to agy first (or use `agy-switch add NAME --login`)."
214
+ )
215
+ dst_dir = profile_dir(name)
216
+ _ensure_private_dir(profiles_dir())
217
+ _ensure_private_dir(dst_dir)
218
+ _atomic_write(dst_dir / TOKEN_FILE, data)
219
+ meta = _read_json(dst_dir / "meta.json")
220
+ meta.setdefault("created_at", _now())
221
+ meta["updated_at"] = _now()
222
+ meta["email"] = token_email(dst_dir / TOKEN_FILE)
223
+ _write_json(dst_dir / "meta.json", meta)
224
+
225
+
226
+ def sync_active_back() -> None:
227
+ """agy refreshes its token on its own; save the newest version back into the
228
+ active profile (only if the live login is still the same account)."""
229
+ active = active_profile()
230
+ if not active:
231
+ return
232
+ live = live_email()
233
+ if live and live == profile_email(active):
234
+ snapshot_live(active)
235
+
236
+
237
+ def restore_profile(name: str) -> None:
238
+ write_live_token((profile_dir(name) / TOKEN_FILE).read_bytes())
239
+
240
+
241
+ def backup_live() -> Path | None:
242
+ data = read_live_token()
243
+ if data is None:
244
+ return None
245
+ bdir = store_home() / "backups"
246
+ _ensure_private_dir(bdir)
247
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
248
+ dst = bdir / f"{TOKEN_FILE}.{stamp}"
249
+ _atomic_write(dst, data)
250
+ # keep only the 10 most recent backups
251
+ old = sorted(bdir.glob(f"{TOKEN_FILE}.*"))[:-10]
252
+ for p in old:
253
+ p.unlink(missing_ok=True)
254
+ return dst
255
+
256
+
257
+ # --------------------------------------------------------------------------- commands
258
+
259
+
260
+ def cmd_add(args: argparse.Namespace) -> int:
261
+ name = validate_name(args.name)
262
+ exists = (profile_dir(name) / TOKEN_FILE).is_file()
263
+ if exists and not args.force:
264
+ raise SwitchError(
265
+ f"profile {name!r} already exists (use --force to overwrite)"
266
+ )
267
+
268
+ if args.login:
269
+ sync_active_back()
270
+ backup = backup_live()
271
+ clear_live_token()
272
+ print(
273
+ "Opening agy to log in. Sign in with the desired account and, once "
274
+ "logged in, exit agy (/exit or Ctrl+C)."
275
+ )
276
+ try:
277
+ subprocess.run([args.agy_bin], check=False)
278
+ except FileNotFoundError:
279
+ if backup:
280
+ write_live_token(backup.read_bytes())
281
+ raise SwitchError(f"executable {args.agy_bin!r} not found")
282
+ except KeyboardInterrupt:
283
+ pass
284
+ if read_live_token() is None:
285
+ if backup:
286
+ write_live_token(backup.read_bytes())
287
+ raise SwitchError("login not completed; previous credentials restored")
288
+
289
+ email = live_email()
290
+ for other in list_profiles():
291
+ if other != name and email and profile_email(other) == email:
292
+ print(
293
+ f"warning: account {email} is already saved in profile {other!r}",
294
+ file=sys.stderr,
295
+ )
296
+ snapshot_live(name)
297
+ state = load_state()
298
+ state["active"] = name
299
+ save_state(state)
300
+ print(f"Profile {name!r} {'updated' if exists else 'added'} ({email or 'unknown email'}) and set as active.")
301
+ return 0
302
+
303
+
304
+ def cmd_list(args: argparse.Namespace) -> int:
305
+ names = list_profiles()
306
+ if not names:
307
+ print("No saved profiles. Use `agy-switch add NAME`.")
308
+ return 0
309
+ active = active_profile()
310
+ width = max(len(n) for n in names)
311
+ for n in names:
312
+ mark = "*" if n == active else " "
313
+ print(f"{mark} {n.ljust(width)} {profile_email(n) or '-'}")
314
+ return 0
315
+
316
+
317
+ def cmd_use(args: argparse.Namespace) -> int:
318
+ name = validate_name(args.name)
319
+ if name not in list_profiles():
320
+ raise SwitchError(f"profile {name!r} does not exist (see `agy-switch list`)")
321
+ if name == active_profile() and live_email() == profile_email(name):
322
+ print(f"Already using {name!r} ({profile_email(name)}).")
323
+ return 0
324
+ sync_active_back()
325
+ backup_live()
326
+ restore_profile(name)
327
+ state = load_state()
328
+ state["active"] = name
329
+ save_state(state)
330
+ print(f"Now using {name!r} ({profile_email(name) or 'unknown email'}).")
331
+ return 0
332
+
333
+
334
+ def cmd_current(args: argparse.Namespace) -> int:
335
+ active = active_profile()
336
+ live = live_email()
337
+ if not active:
338
+ if live:
339
+ print(f"No active profile (agy is logged in as {live}, not saved).")
340
+ else:
341
+ print("No active profile.")
342
+ return 1
343
+ expected = profile_email(active)
344
+ print(f"{active} ({expected or 'unknown email'})")
345
+ if live != expected:
346
+ print(
347
+ f"warning: agy is logged in as {live or 'nobody'}, "
348
+ f"which differs from profile {active!r}",
349
+ file=sys.stderr,
350
+ )
351
+ return 0
352
+
353
+
354
+ def cmd_remove(args: argparse.Namespace) -> int:
355
+ name = validate_name(args.name)
356
+ pdir = profile_dir(name)
357
+ if not pdir.is_dir():
358
+ raise SwitchError(f"profile {name!r} does not exist")
359
+ if not args.yes:
360
+ answer = input(f"Remove profile {name!r} ({profile_email(name) or '-'})? [y/N] ")
361
+ if answer.strip().lower() not in ("y", "yes"):
362
+ print("Cancelled.")
363
+ return 1
364
+ shutil.rmtree(pdir)
365
+ state = load_state()
366
+ if state.get("active") == name:
367
+ state["active"] = None
368
+ save_state(state)
369
+ print(
370
+ f"Profile {name!r} removed. It was the active one; the current agy "
371
+ "login was left unchanged."
372
+ )
373
+ else:
374
+ print(f"Profile {name!r} removed.")
375
+ return 0
376
+
377
+
378
+ # --------------------------------------------------------------------------- cli
379
+
380
+
381
+ def build_parser() -> argparse.ArgumentParser:
382
+ p = argparse.ArgumentParser(
383
+ prog="agy-switch",
384
+ description="Switch between Antigravity CLI (agy) accounts.",
385
+ )
386
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
387
+ sub = p.add_subparsers(dest="command", required=True, metavar="COMMAND")
388
+
389
+ a = sub.add_parser("add", help="save the account currently logged in to agy as a profile")
390
+ a.add_argument("name", metavar="NAME")
391
+ a.add_argument(
392
+ "--login", action="store_true",
393
+ help="log out, open agy to sign in to another account, and save the result",
394
+ )
395
+ a.add_argument("-f", "--force", action="store_true", help="overwrite an existing profile")
396
+ a.add_argument("--agy-bin", default=os.environ.get("AGY_BIN", "agy"), help=argparse.SUPPRESS)
397
+ a.set_defaults(func=cmd_add)
398
+
399
+ ls = sub.add_parser("list", aliases=["ls"], help="list saved profiles")
400
+ ls.set_defaults(func=cmd_list)
401
+
402
+ u = sub.add_parser("use", aliases=["switch"], help="switch to the given profile")
403
+ u.add_argument("name", metavar="NAME")
404
+ u.set_defaults(func=cmd_use)
405
+
406
+ c = sub.add_parser("current", help="show the active profile")
407
+ c.set_defaults(func=cmd_current)
408
+
409
+ r = sub.add_parser("remove", aliases=["rm"], help="remove a saved profile")
410
+ r.add_argument("name", metavar="NAME")
411
+ r.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
412
+ r.set_defaults(func=cmd_remove)
413
+ return p
414
+
415
+
416
+ def main(argv: list[str] | None = None) -> int:
417
+ args = build_parser().parse_args(argv)
418
+ try:
419
+ return args.func(args)
420
+ except SwitchError as e:
421
+ print(f"error: {e}", file=sys.stderr)
422
+ return 1
423
+ except KeyboardInterrupt:
424
+ return 130
425
+
426
+
427
+ if __name__ == "__main__":
428
+ sys.exit(main())
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: agy-switch
3
+ Version: 0.1.0
4
+ Summary: Switch between Antigravity CLI (agy) accounts
5
+ Keywords: agy,antigravity,cli,accounts,profiles
6
+ Author: renatormc
7
+ Author-email: renatormc <renatomartinsrmc@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Utilities
21
+ Requires-Dist: secretstorage>=3.3 ; sys_platform == 'linux'
22
+ Requires-Python: >=3.12
23
+ Project-URL: Homepage, https://github.com/renatormc/agy-switcher
24
+ Project-URL: Repository, https://github.com/renatormc/agy-switcher
25
+ Project-URL: Issues, https://github.com/renatormc/agy-switcher/issues
26
+ Description-Content-Type: text/markdown
27
+
28
+ # agy-switch
29
+
30
+ Switch between multiple **Antigravity CLI** (`agy`) accounts.
31
+
32
+ On Linux, `agy` stores its login in the system keyring (GNOME Keyring, KWallet or another
33
+ Secret Service provider). If no keyring is available, it uses
34
+ `~/.gemini/antigravity-cli/antigravity-oauth-token` instead. `agy-switch` keeps a copy of the
35
+ login for each profile in `~/.agy-switch/profiles/<name>/` and puts the right one back in
36
+ place when you switch accounts.
37
+
38
+ Saved profiles are unencrypted files, protected only by their permissions (0700/0600), even
39
+ when `agy` itself uses the keyring.
40
+
41
+ ## Requirements
42
+
43
+ - Linux or macOS
44
+ - Python 3.12 or newer
45
+ - [pipx](https://pipx.pypa.io/)
46
+ - Antigravity CLI (`agy`) installed and on your `PATH`
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pipx install agy-switch
52
+ ```
53
+
54
+ This installs the `agy-switch` command into `~/.local/bin`. If the shell can't find it,
55
+ add that directory to your `PATH`:
56
+
57
+ ```bash
58
+ pipx ensurepath # then open a new terminal
59
+ ```
60
+
61
+ If your default Python is older than 3.12, point pipx to a newer one:
62
+
63
+ ```bash
64
+ pipx install --python python3.12 agy-switch
65
+ ```
66
+
67
+ Check the installation:
68
+
69
+ ```bash
70
+ agy-switch --version
71
+ ```
72
+
73
+ To update:
74
+
75
+ ```bash
76
+ pipx upgrade agy-switch
77
+ ```
78
+
79
+ To uninstall:
80
+
81
+ ```bash
82
+ pipx uninstall agy-switch
83
+ ```
84
+
85
+ Saved profiles in `~/.agy-switch` are not removed. Delete that directory if you don't need them.
86
+
87
+ ## Usage
88
+
89
+ ```bash
90
+ # 1. With agy logged in to your personal account:
91
+ agy-switch add personal
92
+
93
+ # 2. Add another account (logs out, opens agy so you can sign in, then saves it):
94
+ agy-switch add work --login
95
+
96
+ agy-switch list # * marks the active profile
97
+ agy-switch use personal
98
+ agy-switch use work
99
+ agy-switch current
100
+ agy-switch remove personal # asks for confirmation (-y to skip)
101
+ ```
102
+
103
+ | Command | Description |
104
+ | --- | --- |
105
+ | `add NAME [--login] [--force]` | Save the current agy login as a profile. `--login` signs in to a new account first. `--force` overwrites an existing profile. |
106
+ | `list` (`ls`) | List profiles and their emails. |
107
+ | `use NAME` (`switch`) | Switch agy to the given profile. |
108
+ | `current` | Show the active profile and warn if agy is logged in to a different account. |
109
+ | `remove NAME [-y]` (`rm`) | Delete a saved profile. |
110
+
111
+ ## Details
112
+
113
+ - `agy` refreshes its token on its own. Before switching accounts, `agy-switch` saves the
114
+ current token back into the active profile, as long as the email still matches. This
115
+ keeps profiles from holding an expired token.
116
+ - Before overwriting the login, `agy-switch` saves a backup to `~/.agy-switch/backups/`
117
+ (keeps the 10 most recent).
118
+ - Close any running `agy` sessions before switching accounts.
119
+ - On macOS, only the file is supported. A login that `agy` keeps in the Keychain isn't detected.
120
+ - Environment variables: `AGY_HOME` (default `~/.gemini/antigravity-cli`) and
121
+ `AGY_SWITCH_HOME` (default `~/.agy-switch`).
122
+
123
+ ## Development
124
+
125
+ Run from a clone with [uv](https://docs.astral.sh/uv/):
126
+
127
+ ```bash
128
+ uv run agy-switch --help
129
+ ```
130
+
131
+ To publish a release, set the new version, then build and upload it:
132
+
133
+ ```bash
134
+ uv version --bump patch # or minor / major
135
+ rm -rf dist
136
+ uv build
137
+ uv publish # needs a PyPI token in UV_PUBLISH_TOKEN
138
+ ```
@@ -0,0 +1,6 @@
1
+ agy_switch/__init__.py,sha256=si2Bj6iOrnXsv5G5xR3uGP6bU2yRRTN6P5ScVYneK9k,13254
2
+ agy_switch-0.1.0.dist-info/licenses/LICENSE,sha256=O9bh88qiZLyAjVjMMThOVB9uqIPbcoPdCcO56GdmuvY,1066
3
+ agy_switch-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
4
+ agy_switch-0.1.0.dist-info/entry_points.txt,sha256=toLwT8nHpuhktLK6_D0jWKNWp0XWhKySlhWGXswuAkU,48
5
+ agy_switch-0.1.0.dist-info/METADATA,sha256=GwPKYN5k2hKsKuqjX0cBYOYhL2yE7-sPoFV6-1_RX9Y,4264
6
+ agy_switch-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ agy-switch = agy_switch:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 renatormc
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.