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.
@@ -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,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,3 @@
1
+ [console_scripts]
2
+ agyswap = agyswap.cli:main
3
+ agyswap-statusline = agyswap.statusline:main
@@ -0,0 +1,4 @@
1
+ rich>=13.0
2
+
3
+ [tui]
4
+ textual>=1.0
@@ -0,0 +1,3 @@
1
+ agyswap
2
+ build
3
+ dist
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from agyswap.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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()