agy-switch 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agy_switch-0.1.0/LICENSE +21 -0
- agy_switch-0.1.0/PKG-INFO +138 -0
- agy_switch-0.1.0/README.md +111 -0
- agy_switch-0.1.0/pyproject.toml +45 -0
- agy_switch-0.1.0/pyproject.toml.orig +40 -0
- agy_switch-0.1.0/src/agy_switch/__init__.py +428 -0
agy_switch-0.1.0/LICENSE
ADDED
|
@@ -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.
|
|
@@ -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,111 @@
|
|
|
1
|
+
# agy-switch
|
|
2
|
+
|
|
3
|
+
Switch between multiple **Antigravity CLI** (`agy`) accounts.
|
|
4
|
+
|
|
5
|
+
On Linux, `agy` stores its login in the system keyring (GNOME Keyring, KWallet or another
|
|
6
|
+
Secret Service provider). If no keyring is available, it uses
|
|
7
|
+
`~/.gemini/antigravity-cli/antigravity-oauth-token` instead. `agy-switch` keeps a copy of the
|
|
8
|
+
login for each profile in `~/.agy-switch/profiles/<name>/` and puts the right one back in
|
|
9
|
+
place when you switch accounts.
|
|
10
|
+
|
|
11
|
+
Saved profiles are unencrypted files, protected only by their permissions (0700/0600), even
|
|
12
|
+
when `agy` itself uses the keyring.
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Linux or macOS
|
|
17
|
+
- Python 3.12 or newer
|
|
18
|
+
- [pipx](https://pipx.pypa.io/)
|
|
19
|
+
- Antigravity CLI (`agy`) installed and on your `PATH`
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pipx install agy-switch
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This installs the `agy-switch` command into `~/.local/bin`. If the shell can't find it,
|
|
28
|
+
add that directory to your `PATH`:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pipx ensurepath # then open a new terminal
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If your default Python is older than 3.12, point pipx to a newer one:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pipx install --python python3.12 agy-switch
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Check the installation:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
agy-switch --version
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
To update:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pipx upgrade agy-switch
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
To uninstall:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pipx uninstall agy-switch
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Saved profiles in `~/.agy-switch` are not removed. Delete that directory if you don't need them.
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# 1. With agy logged in to your personal account:
|
|
64
|
+
agy-switch add personal
|
|
65
|
+
|
|
66
|
+
# 2. Add another account (logs out, opens agy so you can sign in, then saves it):
|
|
67
|
+
agy-switch add work --login
|
|
68
|
+
|
|
69
|
+
agy-switch list # * marks the active profile
|
|
70
|
+
agy-switch use personal
|
|
71
|
+
agy-switch use work
|
|
72
|
+
agy-switch current
|
|
73
|
+
agy-switch remove personal # asks for confirmation (-y to skip)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Command | Description |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `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. |
|
|
79
|
+
| `list` (`ls`) | List profiles and their emails. |
|
|
80
|
+
| `use NAME` (`switch`) | Switch agy to the given profile. |
|
|
81
|
+
| `current` | Show the active profile and warn if agy is logged in to a different account. |
|
|
82
|
+
| `remove NAME [-y]` (`rm`) | Delete a saved profile. |
|
|
83
|
+
|
|
84
|
+
## Details
|
|
85
|
+
|
|
86
|
+
- `agy` refreshes its token on its own. Before switching accounts, `agy-switch` saves the
|
|
87
|
+
current token back into the active profile, as long as the email still matches. This
|
|
88
|
+
keeps profiles from holding an expired token.
|
|
89
|
+
- Before overwriting the login, `agy-switch` saves a backup to `~/.agy-switch/backups/`
|
|
90
|
+
(keeps the 10 most recent).
|
|
91
|
+
- Close any running `agy` sessions before switching accounts.
|
|
92
|
+
- On macOS, only the file is supported. A login that `agy` keeps in the Keychain isn't detected.
|
|
93
|
+
- Environment variables: `AGY_HOME` (default `~/.gemini/antigravity-cli`) and
|
|
94
|
+
`AGY_SWITCH_HOME` (default `~/.agy-switch`).
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
Run from a clone with [uv](https://docs.astral.sh/uv/):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv run agy-switch --help
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
To publish a release, set the new version, then build and upload it:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv version --bump patch # or minor / major
|
|
108
|
+
rm -rf dist
|
|
109
|
+
uv build
|
|
110
|
+
uv publish # needs a PyPI token in UV_PUBLISH_TOKEN
|
|
111
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agy-switch"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Switch between Antigravity CLI (agy) accounts"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
keywords = [
|
|
10
|
+
"agy",
|
|
11
|
+
"antigravity",
|
|
12
|
+
"cli",
|
|
13
|
+
"accounts",
|
|
14
|
+
"profiles",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Utilities",
|
|
28
|
+
]
|
|
29
|
+
dependencies = ["secretstorage>=3.3; sys_platform == 'linux'"]
|
|
30
|
+
|
|
31
|
+
[[project.authors]]
|
|
32
|
+
name = "renatormc"
|
|
33
|
+
email = "renatomartinsrmc@gmail.com"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/renatormc/agy-switcher"
|
|
37
|
+
Repository = "https://github.com/renatormc/agy-switcher"
|
|
38
|
+
Issues = "https://github.com/renatormc/agy-switcher/issues"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
agy-switch = "agy_switch:main"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.13.7,<0.14.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agy-switch"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Switch between Antigravity CLI (agy) accounts"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "renatormc", email = "renatomartinsrmc@gmail.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
keywords = ["agy", "antigravity", "cli", "accounts", "profiles"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: MacOS",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Utilities",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"secretstorage>=3.3; sys_platform == 'linux'",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/renatormc/agy-switcher"
|
|
32
|
+
Repository = "https://github.com/renatormc/agy-switcher"
|
|
33
|
+
Issues = "https://github.com/renatormc/agy-switcher/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
agy-switch = "agy_switch:main"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["uv_build>=0.13.7,<0.14.0"]
|
|
40
|
+
build-backend = "uv_build"
|
|
@@ -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())
|