agy-swap 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_swap-0.1.0/PKG-INFO +10 -0
- agy_swap-0.1.0/README.md +178 -0
- agy_swap-0.1.0/agy_swap.egg-info/PKG-INFO +10 -0
- agy_swap-0.1.0/agy_swap.egg-info/SOURCES.txt +23 -0
- agy_swap-0.1.0/agy_swap.egg-info/dependency_links.txt +1 -0
- agy_swap-0.1.0/agy_swap.egg-info/entry_points.txt +3 -0
- agy_swap-0.1.0/agy_swap.egg-info/requires.txt +4 -0
- agy_swap-0.1.0/agy_swap.egg-info/top_level.txt +3 -0
- agy_swap-0.1.0/agyswap/__init__.py +1 -0
- agy_swap-0.1.0/agyswap/__main__.py +4 -0
- agy_swap-0.1.0/agyswap/cli.py +246 -0
- agy_swap-0.1.0/agyswap/models.py +62 -0
- agy_swap-0.1.0/agyswap/paths.py +10 -0
- agy_swap-0.1.0/agyswap/statusline.py +93 -0
- agy_swap-0.1.0/agyswap/switcher.py +458 -0
- agy_swap-0.1.0/agyswap/tui/__init__.py +0 -0
- agy_swap-0.1.0/agyswap/tui/agy.tcss +58 -0
- agy_swap-0.1.0/agyswap/tui/app.py +130 -0
- agy_swap-0.1.0/agyswap/tui/dashboard.py +213 -0
- agy_swap-0.1.0/agyswap/tui/quota_probe.py +149 -0
- agy_swap-0.1.0/agyswap/tui/theme.py +50 -0
- agy_swap-0.1.0/agyswap/tui/watch.py +135 -0
- agy_swap-0.1.0/agyswap/tui/widgets.py +299 -0
- agy_swap-0.1.0/pyproject.toml +27 -0
- agy_swap-0.1.0/setup.cfg +4 -0
agy_swap-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agy-swap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Multi-account switcher for Antigravity CLI (agy)
|
|
5
|
+
Project-URL: Homepage, https://github.com/pr656d/agy-swap
|
|
6
|
+
Project-URL: Repository, https://github.com/pr656d/agy-swap
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: rich>=13.0
|
|
9
|
+
Provides-Extra: tui
|
|
10
|
+
Requires-Dist: textual>=1.0; extra == "tui"
|
agy_swap-0.1.0/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# agy-swap
|
|
2
|
+
|
|
3
|
+
Multi-account switcher for Antigravity CLI (agy). Easily switch between multiple Google/Gemini accounts without logging out. Track Gemini API quota for every account in a live TUI dashboard.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
### Using uv (recommended)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv tool install agy-swap
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Using pipx
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pipx install agy-swap
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### With TUI dashboard
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv tool install 'agy-swap[tui]' # uv
|
|
23
|
+
pipx install 'agy-swap[tui]' # pipx
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### From source
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/pr656d/agy-swap.git
|
|
30
|
+
cd agy-swap
|
|
31
|
+
uv sync
|
|
32
|
+
uv run agyswap help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Updating
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv tool upgrade agy-swap # uv
|
|
39
|
+
pipx upgrade agy-swap # pipx
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### Add your first account
|
|
45
|
+
|
|
46
|
+
Log into agy with your first account, then:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
agyswap add
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Add more accounts
|
|
53
|
+
|
|
54
|
+
Log in with another account, then:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
agyswap add
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Switch accounts
|
|
61
|
+
|
|
62
|
+
Rotate to the next enabled account:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
agyswap switch
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or switch to a specific account:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
agyswap switch 2
|
|
72
|
+
agyswap switch user@gmail.com
|
|
73
|
+
agyswap switch work # by alias, once set with `agyswap alias 2 work`
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
See them all at a glance:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
agyswap list
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Interactive dashboard (TUI)
|
|
83
|
+
|
|
84
|
+
Run `agyswap tui` for the full-screen dashboard — every account's 5-hour and weekly usage with live progress bars, reset times, and switching, all keyboard-driven. `agyswap watch` opens it straight to the live monitor.
|
|
85
|
+
|
|
86
|
+
Keyboard shortcuts from the dashboard:
|
|
87
|
+
|
|
88
|
+
| Key | Action |
|
|
89
|
+
|-----|--------|
|
|
90
|
+
| `s` | Open account picker to switch |
|
|
91
|
+
| `w` | Switch to watch screen |
|
|
92
|
+
| `a` | Add current account |
|
|
93
|
+
| `f` | Refresh |
|
|
94
|
+
| `j`/`k` | Navigate menu |
|
|
95
|
+
| `q` | Quit |
|
|
96
|
+
|
|
97
|
+
### Aliases
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
agyswap alias 2 work # give account 2 the alias "work"
|
|
101
|
+
agyswap alias 2 --unset # remove alias
|
|
102
|
+
agyswap alias # list all aliases
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Disable / enable
|
|
106
|
+
|
|
107
|
+
Hold an account out of rotation without removing its credentials:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
agyswap disable 2
|
|
111
|
+
agyswap enable 2
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Remove an account
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
agyswap remove 2
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Quota data
|
|
121
|
+
|
|
122
|
+
Quota usage appears in `agyswap list` and the TUI. Two ways to populate it:
|
|
123
|
+
|
|
124
|
+
- **Automatic (TUI only)** — when agy is running, the TUI probes its local language server via ConnectRPC every 3 seconds and writes quota to cache. No setup needed.
|
|
125
|
+
- **Statusline** — run `agy` with the statusline script to capture quota on every prompt:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
agy -S "python3 -m agyswap.statusline"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Add it as a shell alias:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
alias agy='agy -S "python3 -m agyswap.statusline"'
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The script writes to `~/.agy-swap/cache/quota.json` which the TUI and CLI read.
|
|
138
|
+
|
|
139
|
+
## How it works
|
|
140
|
+
|
|
141
|
+
- Backs up OAuth tokens when you add an account (`~/.agy-swap/credentials/`)
|
|
142
|
+
- Swaps only the account-specific login by replacing the active token and Google accounts file
|
|
143
|
+
- Account credentials stored securely using platform-appropriate methods (macOS Keychain + file fallback)
|
|
144
|
+
- Quota fetched from agy's local gRPC language server when running, or captured via the `-S` statusline mechanism
|
|
145
|
+
|
|
146
|
+
## Data locations
|
|
147
|
+
|
|
148
|
+
| Data | Path |
|
|
149
|
+
|------|------|
|
|
150
|
+
| Credential backups | `~/.agy-swap/credentials/` |
|
|
151
|
+
| Account registry | `~/.agy-swap/sequence.json` |
|
|
152
|
+
| Quota cache | `~/.agy-swap/cache/quota.json` |
|
|
153
|
+
|
|
154
|
+
## Uninstall
|
|
155
|
+
|
|
156
|
+
Remove all data:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
rm -rf ~/.agy-swap
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Then uninstall the tool:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
uv tool uninstall agy-swap
|
|
166
|
+
# or
|
|
167
|
+
pipx uninstall agy-swap
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Requirements
|
|
171
|
+
|
|
172
|
+
- Python 3.10+
|
|
173
|
+
- Antigravity CLI (agy) installed and logged in
|
|
174
|
+
- macOS, Linux
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agy-swap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Multi-account switcher for Antigravity CLI (agy)
|
|
5
|
+
Project-URL: Homepage, https://github.com/pr656d/agy-swap
|
|
6
|
+
Project-URL: Repository, https://github.com/pr656d/agy-swap
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: rich>=13.0
|
|
9
|
+
Provides-Extra: tui
|
|
10
|
+
Requires-Dist: textual>=1.0; extra == "tui"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
agy_swap.egg-info/PKG-INFO
|
|
4
|
+
agy_swap.egg-info/SOURCES.txt
|
|
5
|
+
agy_swap.egg-info/dependency_links.txt
|
|
6
|
+
agy_swap.egg-info/entry_points.txt
|
|
7
|
+
agy_swap.egg-info/requires.txt
|
|
8
|
+
agy_swap.egg-info/top_level.txt
|
|
9
|
+
agyswap/__init__.py
|
|
10
|
+
agyswap/__main__.py
|
|
11
|
+
agyswap/cli.py
|
|
12
|
+
agyswap/models.py
|
|
13
|
+
agyswap/paths.py
|
|
14
|
+
agyswap/statusline.py
|
|
15
|
+
agyswap/switcher.py
|
|
16
|
+
agyswap/tui/__init__.py
|
|
17
|
+
agyswap/tui/agy.tcss
|
|
18
|
+
agyswap/tui/app.py
|
|
19
|
+
agyswap/tui/dashboard.py
|
|
20
|
+
agyswap/tui/quota_probe.py
|
|
21
|
+
agyswap/tui/theme.py
|
|
22
|
+
agyswap/tui/watch.py
|
|
23
|
+
agyswap/tui/widgets.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from agyswap import __version__
|
|
5
|
+
from agyswap.switcher import AgySwitcher, AccountNotFoundError, AgyError
|
|
6
|
+
from agyswap.paths import GOOGLE_ACCOUNTS_PATH
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich import box
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cmd_list(sw: AgySwitcher, args) -> None:
|
|
16
|
+
rows = sw.list_accounts()
|
|
17
|
+
if not rows:
|
|
18
|
+
console.print("[dim]No accounts managed yet.[/dim]")
|
|
19
|
+
console.print("Login with [bold]agy[/bold] first, then run [bold]agyswap add[/bold].")
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
table = Table(box=box.ROUNDED)
|
|
23
|
+
table.add_column("#", style="bold")
|
|
24
|
+
table.add_column("Email")
|
|
25
|
+
table.add_column("Alias")
|
|
26
|
+
table.add_column("Status")
|
|
27
|
+
table.add_column("Token")
|
|
28
|
+
|
|
29
|
+
for r in rows:
|
|
30
|
+
num = str(r["number"])
|
|
31
|
+
email = r["email"]
|
|
32
|
+
alias = r["alias"] or ""
|
|
33
|
+
status_parts = []
|
|
34
|
+
if r["active"]:
|
|
35
|
+
status_parts.append("[green]active[/green]")
|
|
36
|
+
if r["disabled"]:
|
|
37
|
+
status_parts.append("[yellow]disabled[/yellow]")
|
|
38
|
+
status = " ".join(status_parts) if status_parts else "[dim]—[/dim]"
|
|
39
|
+
token = "[green]✓[/green]" if r["has_token"] else "[red]✗[/red]"
|
|
40
|
+
table.add_row(num, email, alias, status, token)
|
|
41
|
+
|
|
42
|
+
console.print(table)
|
|
43
|
+
console.print(f"\n[dim]Active account tracked at: {GOOGLE_ACCOUNTS_PATH}[/dim]")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_status(sw: AgySwitcher, args) -> None:
|
|
47
|
+
info = sw.status()
|
|
48
|
+
if info["active"] is None:
|
|
49
|
+
console.print("[yellow]No active agy login detected.[/yellow]")
|
|
50
|
+
console.print("Run [bold]agy[/bold] to login, then [bold]agyswap add[/bold].")
|
|
51
|
+
return
|
|
52
|
+
managed = "yes" if info["managed"] else "[yellow]not managed[/yellow]"
|
|
53
|
+
console.print(f"Active account: [bold]{info['active']}[/bold]")
|
|
54
|
+
console.print(f"Managed: {managed}")
|
|
55
|
+
if info["number"]:
|
|
56
|
+
console.print(f"Slot: #{info['number']}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_add(sw: AgySwitcher, args) -> None:
|
|
60
|
+
acc = sw.add_current(email_override=args.email)
|
|
61
|
+
console.print(f"[green]Added[/green] account #{acc.number}: {acc.email}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_remove(sw: AgySwitcher, args) -> None:
|
|
65
|
+
try:
|
|
66
|
+
acc = sw._resolve_account(args.identifier)
|
|
67
|
+
sw.remove_account(args.identifier)
|
|
68
|
+
console.print(f"[red]Removed[/red] account #{acc.number}: {acc.email}")
|
|
69
|
+
except AccountNotFoundError as e:
|
|
70
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_switch(sw: AgySwitcher, args) -> None:
|
|
75
|
+
try:
|
|
76
|
+
result = sw.switch(args.identifier)
|
|
77
|
+
prev = result["previous"] or "(none)"
|
|
78
|
+
console.print(
|
|
79
|
+
f"Switched: [dim]{prev}[/dim] → [bold green]{result['active']}[/bold green]"
|
|
80
|
+
)
|
|
81
|
+
except (AgyError, AccountNotFoundError) as e:
|
|
82
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_disable(sw: AgySwitcher, args) -> None:
|
|
87
|
+
try:
|
|
88
|
+
acc = sw.set_disabled(args.identifier, True)
|
|
89
|
+
console.print(f"[yellow]Disabled[/yellow] account #{acc.number}: {acc.email}")
|
|
90
|
+
except AccountNotFoundError as e:
|
|
91
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_enable(sw: AgySwitcher, args) -> None:
|
|
96
|
+
try:
|
|
97
|
+
acc = sw.set_disabled(args.identifier, False)
|
|
98
|
+
console.print(f"[green]Enabled[/green] account #{acc.number}: {acc.email}")
|
|
99
|
+
except AccountNotFoundError as e:
|
|
100
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_alias(sw: AgySwitcher, args) -> None:
|
|
105
|
+
try:
|
|
106
|
+
if args.unset:
|
|
107
|
+
acc = sw.unalias(args.identifier)
|
|
108
|
+
console.print(f"Removed alias for #{acc.number}: {acc.email}")
|
|
109
|
+
elif args.name:
|
|
110
|
+
acc = sw.alias(args.identifier, args.name)
|
|
111
|
+
console.print(f"Set alias [bold]'{args.name}'[/bold] for #{acc.number}: {acc.email}")
|
|
112
|
+
else:
|
|
113
|
+
rows = sw.list_accounts()
|
|
114
|
+
found = False
|
|
115
|
+
for r in rows:
|
|
116
|
+
if r["alias"]:
|
|
117
|
+
console.print(f" {r['number']}: {r['alias']} [dim]({r['email']})[/dim]")
|
|
118
|
+
found = True
|
|
119
|
+
if not found:
|
|
120
|
+
console.print("[dim]No aliases set.[/dim]")
|
|
121
|
+
except AccountNotFoundError as e:
|
|
122
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_tui(sw: AgySwitcher, args) -> None:
|
|
127
|
+
try:
|
|
128
|
+
from agyswap.tui.app import run_tui
|
|
129
|
+
run_tui(sw, start_watch=False)
|
|
130
|
+
except ImportError:
|
|
131
|
+
console.print("[red]TUI requires textual. Install:[/red]")
|
|
132
|
+
console.print(" pip install 'agy-swap[tui]'")
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_watch(sw: AgySwitcher, args) -> None:
|
|
137
|
+
try:
|
|
138
|
+
from agyswap.tui.app import run_tui
|
|
139
|
+
run_tui(sw, start_watch=True)
|
|
140
|
+
except ImportError:
|
|
141
|
+
console.print("[red]TUI requires textual. Install:[/red]")
|
|
142
|
+
console.print(" pip install 'agy-swap[tui]'")
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_statusline(sw, args) -> None:
|
|
147
|
+
if args.setup:
|
|
148
|
+
module = "agyswap.statusline"
|
|
149
|
+
console.print("Add this alias to your shell:")
|
|
150
|
+
console.print(f" alias agy='agy -S \"python3 -m {module}\" ")
|
|
151
|
+
console.print()
|
|
152
|
+
console.print("Or use it once:")
|
|
153
|
+
console.print(f" agy -S \"python3 -m {module}\"")
|
|
154
|
+
console.print()
|
|
155
|
+
console.print("[dim]The statusline script captures quota data to[/dim]")
|
|
156
|
+
console.print(f"[dim] ~/.agy-swap/cache/quota.json[/dim]")
|
|
157
|
+
console.print("[dim]so agyswap TUI can display it.[/dim]")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
from agyswap.statusline import main as sl_main
|
|
161
|
+
sl_main()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main() -> None:
|
|
165
|
+
parser = argparse.ArgumentParser(
|
|
166
|
+
prog="agyswap",
|
|
167
|
+
description="Multi-account switcher for Antigravity CLI (agy)",
|
|
168
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
169
|
+
epilog="""Examples:
|
|
170
|
+
agyswap add capture current login as a managed account
|
|
171
|
+
agyswap list list managed accounts
|
|
172
|
+
agyswap switch rotate to next enabled account
|
|
173
|
+
agyswap switch 2 switch to account #2
|
|
174
|
+
agyswap switch email switch to account by email
|
|
175
|
+
agyswap disable 2 hold an account out of rotation
|
|
176
|
+
agyswap enable 2 return an account to rotation
|
|
177
|
+
agyswap remove 2 remove an account
|
|
178
|
+
agyswap alias 2 work set an alias
|
|
179
|
+
agyswap alias 2 --unset remove an alias
|
|
180
|
+
agyswap tui interactive dashboard
|
|
181
|
+
agyswap watch dashboard on the watch page
|
|
182
|
+
""",
|
|
183
|
+
)
|
|
184
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
185
|
+
|
|
186
|
+
sub = parser.add_subparsers(dest="command")
|
|
187
|
+
|
|
188
|
+
p_list = sub.add_parser("list", help="list managed accounts")
|
|
189
|
+
p_status = sub.add_parser("status", help="show current active account")
|
|
190
|
+
p_add = sub.add_parser("add", help="capture current agy login as a managed account")
|
|
191
|
+
p_add.add_argument("--email", help="email for the account (auto-detected if omitted)")
|
|
192
|
+
|
|
193
|
+
p_switch = sub.add_parser("switch", help="switch accounts")
|
|
194
|
+
p_switch.add_argument("identifier", nargs="?", help="account number, email, or alias")
|
|
195
|
+
|
|
196
|
+
p_remove = sub.add_parser("remove", aliases=["rm"], help="remove an account")
|
|
197
|
+
p_remove.add_argument("identifier", help="account number, email, or alias")
|
|
198
|
+
|
|
199
|
+
p_disable = sub.add_parser("disable", help="hold account out of rotation")
|
|
200
|
+
p_disable.add_argument("identifier", help="account number, email, or alias")
|
|
201
|
+
|
|
202
|
+
p_enable = sub.add_parser("enable", help="return account to rotation")
|
|
203
|
+
p_enable.add_argument("identifier", help="account number, email, or alias")
|
|
204
|
+
|
|
205
|
+
p_alias = sub.add_parser("alias", help="manage account aliases")
|
|
206
|
+
p_alias.add_argument("identifier", nargs="?", help="account number, email, or alias")
|
|
207
|
+
p_alias.add_argument("name", nargs="?", help="alias name")
|
|
208
|
+
p_alias.add_argument("--unset", action="store_true", help="remove alias")
|
|
209
|
+
|
|
210
|
+
sub.add_parser("tui", help="launch interactive dashboard")
|
|
211
|
+
sub.add_parser("watch", help="dashboard on the watch page")
|
|
212
|
+
|
|
213
|
+
p_sl = sub.add_parser("statusline", help="capture quota data from agy -S")
|
|
214
|
+
p_sl.add_argument("--setup", action="store_true",
|
|
215
|
+
help="print setup instructions")
|
|
216
|
+
|
|
217
|
+
args = parser.parse_args()
|
|
218
|
+
|
|
219
|
+
sw = AgySwitcher()
|
|
220
|
+
|
|
221
|
+
if not args.command:
|
|
222
|
+
cmd_tui(sw, args)
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
dispatch = {
|
|
226
|
+
"list": cmd_list,
|
|
227
|
+
"status": cmd_status,
|
|
228
|
+
"add": cmd_add,
|
|
229
|
+
"switch": cmd_switch,
|
|
230
|
+
"remove": cmd_remove,
|
|
231
|
+
"rm": cmd_remove,
|
|
232
|
+
"disable": cmd_disable,
|
|
233
|
+
"enable": cmd_enable,
|
|
234
|
+
"alias": cmd_alias,
|
|
235
|
+
"tui": cmd_tui,
|
|
236
|
+
"watch": cmd_watch,
|
|
237
|
+
"statusline": cmd_statusline,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
handler = dispatch.get(args.command)
|
|
241
|
+
if handler:
|
|
242
|
+
handler(sw, args)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
main()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def now_iso() -> str:
|
|
7
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Account:
|
|
12
|
+
number: int
|
|
13
|
+
email: str
|
|
14
|
+
disabled: bool = False
|
|
15
|
+
alias: Optional[str] = None
|
|
16
|
+
created_at: str = field(default_factory=now_iso)
|
|
17
|
+
updated_at: str = field(default_factory=now_iso)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SequenceData:
|
|
22
|
+
active_email: Optional[str] = None
|
|
23
|
+
sequence: list[int] = field(default_factory=list)
|
|
24
|
+
accounts: dict[int, Account] = field(default_factory=dict)
|
|
25
|
+
last_updated: str = field(default_factory=now_iso)
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict:
|
|
28
|
+
return {
|
|
29
|
+
"activeEmail": self.active_email,
|
|
30
|
+
"lastUpdated": self.last_updated,
|
|
31
|
+
"sequence": self.sequence,
|
|
32
|
+
"accounts": {
|
|
33
|
+
str(num): {
|
|
34
|
+
"email": acc.email,
|
|
35
|
+
"disabled": acc.disabled,
|
|
36
|
+
"alias": acc.alias,
|
|
37
|
+
"createdAt": acc.created_at,
|
|
38
|
+
"updatedAt": acc.updated_at,
|
|
39
|
+
}
|
|
40
|
+
for num, acc in self.accounts.items()
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, data: dict) -> "SequenceData":
|
|
46
|
+
accounts = {}
|
|
47
|
+
for num_str, acc_data in data.get("accounts", {}).items():
|
|
48
|
+
num = int(num_str)
|
|
49
|
+
accounts[num] = Account(
|
|
50
|
+
number=num,
|
|
51
|
+
email=acc_data.get("email", ""),
|
|
52
|
+
disabled=acc_data.get("disabled", False),
|
|
53
|
+
alias=acc_data.get("alias"),
|
|
54
|
+
created_at=acc_data.get("createdAt", ""),
|
|
55
|
+
updated_at=acc_data.get("updatedAt", ""),
|
|
56
|
+
)
|
|
57
|
+
return cls(
|
|
58
|
+
active_email=data.get("activeEmail"),
|
|
59
|
+
sequence=data.get("sequence", []),
|
|
60
|
+
accounts=accounts,
|
|
61
|
+
last_updated=data.get("lastUpdated", ""),
|
|
62
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
GEMINI_DIR = Path.home() / ".gemini"
|
|
4
|
+
AGY_CLI_DIR = GEMINI_DIR / "antigravity-cli"
|
|
5
|
+
AGY_TOKEN_PATH = AGY_CLI_DIR / "antigravity-oauth-token"
|
|
6
|
+
GOOGLE_ACCOUNTS_PATH = GEMINI_DIR / "google_accounts.json"
|
|
7
|
+
BACKUP_ROOT = Path.home() / ".agy-swap"
|
|
8
|
+
SEQUENCE_FILE = BACKUP_ROOT / "sequence.json"
|
|
9
|
+
CREDENTIALS_DIR = BACKUP_ROOT / "credentials"
|
|
10
|
+
QUOTA_CACHE = BACKUP_ROOT / "cache" / "quota.json"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Statusline capture script for agy -S.
|
|
3
|
+
|
|
4
|
+
Run: agy -S "python3 -m agyswap.statusline"
|
|
5
|
+
|
|
6
|
+
Captures the statusline JSON from stdin and writes quota data to the
|
|
7
|
+
cache file so agyswap TUI can display it.
|
|
8
|
+
|
|
9
|
+
Also prints a compact statusline to stdout.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
CACHE = Path.home() / ".agy-swap" / "cache" / "quota.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
raw = sys.stdin.read()
|
|
21
|
+
if not raw.strip():
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
data = json.loads(raw)
|
|
26
|
+
except json.JSONDecodeError:
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
context = data.get("context_window", {})
|
|
30
|
+
rate = data.get("rate_limits", {})
|
|
31
|
+
model = data.get("model", {}).get("display_name", "?")
|
|
32
|
+
email_claim = ""
|
|
33
|
+
for token_key in ("access_token", "id_token"):
|
|
34
|
+
full = data.get(token_key, "")
|
|
35
|
+
if not full:
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
payload_b64 = full.split(".")[1]
|
|
39
|
+
padding = 4 - len(payload_b64) % 4
|
|
40
|
+
if padding != 4:
|
|
41
|
+
payload_b64 += "=" * padding
|
|
42
|
+
import base64
|
|
43
|
+
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
44
|
+
email_claim = payload.get("email", "")
|
|
45
|
+
if email_claim:
|
|
46
|
+
break
|
|
47
|
+
except Exception:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
quota = {
|
|
51
|
+
"accounts": {},
|
|
52
|
+
"model": model,
|
|
53
|
+
"captured_at": int(time.time()),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
email = data.get("email", email_claim)
|
|
57
|
+
|
|
58
|
+
five = rate.get("five_hour", {})
|
|
59
|
+
seven = rate.get("seven_day", {})
|
|
60
|
+
|
|
61
|
+
account_quota = {}
|
|
62
|
+
if five:
|
|
63
|
+
account_quota["five_hour"] = five.get("used_percentage")
|
|
64
|
+
account_quota["five_resets_at"] = five.get("resets_at")
|
|
65
|
+
if seven:
|
|
66
|
+
account_quota["seven_day"] = seven.get("used_percentage")
|
|
67
|
+
account_quota["seven_resets_at"] = seven.get("resets_at")
|
|
68
|
+
|
|
69
|
+
if email:
|
|
70
|
+
quota["accounts"][email] = account_quota
|
|
71
|
+
elif account_quota:
|
|
72
|
+
quota["accounts"]["__unknown__"] = account_quota
|
|
73
|
+
|
|
74
|
+
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
CACHE.write_text(json.dumps(quota, indent=2))
|
|
76
|
+
|
|
77
|
+
ctx_tokens = context.get("total_input_tokens", 0)
|
|
78
|
+
ctx_size = context.get("context_window_size", 0)
|
|
79
|
+
five_pct = five.get("used_percentage", "")
|
|
80
|
+
seven_pct = seven.get("used_percentage", "")
|
|
81
|
+
|
|
82
|
+
parts = [model]
|
|
83
|
+
if ctx_tokens and ctx_size:
|
|
84
|
+
parts.append(f"Ctx: {ctx_tokens // 1000}k/{ctx_size // 1000}k")
|
|
85
|
+
if five_pct != "":
|
|
86
|
+
parts.append(f"5h: {five_pct}%")
|
|
87
|
+
if seven_pct != "":
|
|
88
|
+
parts.append(f"7d: {seven_pct}%")
|
|
89
|
+
print(" ".join(parts), flush=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|