antigravity-manager 1.0.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.
Files changed (52) hide show
  1. antigravity_manager-1.0.0/PKG-INFO +81 -0
  2. antigravity_manager-1.0.0/README.md +45 -0
  3. antigravity_manager-1.0.0/pyproject.toml +123 -0
  4. antigravity_manager-1.0.0/setup.cfg +4 -0
  5. antigravity_manager-1.0.0/src/antigravity_manager/__init__.py +3 -0
  6. antigravity_manager-1.0.0/src/antigravity_manager/backup.py +315 -0
  7. antigravity_manager-1.0.0/src/antigravity_manager/banner.py +55 -0
  8. antigravity_manager-1.0.0/src/antigravity_manager/cli.py +674 -0
  9. antigravity_manager-1.0.0/src/antigravity_manager/config.py +39 -0
  10. antigravity_manager-1.0.0/src/antigravity_manager/cooldown.py +249 -0
  11. antigravity_manager-1.0.0/src/antigravity_manager/credentials.py +152 -0
  12. antigravity_manager-1.0.0/src/antigravity_manager/doctor.py +90 -0
  13. antigravity_manager-1.0.0/src/antigravity_manager/list_backups.py +205 -0
  14. antigravity_manager-1.0.0/src/antigravity_manager/profile.py +49 -0
  15. antigravity_manager-1.0.0/src/antigravity_manager/prune.py +125 -0
  16. antigravity_manager-1.0.0/src/antigravity_manager/prune_backups.py +77 -0
  17. antigravity_manager-1.0.0/src/antigravity_manager/purge.py +124 -0
  18. antigravity_manager-1.0.0/src/antigravity_manager/registry.py +45 -0
  19. antigravity_manager-1.0.0/src/antigravity_manager/remove.py +112 -0
  20. antigravity_manager-1.0.0/src/antigravity_manager/restore.py +298 -0
  21. antigravity_manager-1.0.0/src/antigravity_manager/status.py +300 -0
  22. antigravity_manager-1.0.0/src/antigravity_manager/sync.py +145 -0
  23. antigravity_manager-1.0.0/src/antigravity_manager/ui.py +104 -0
  24. antigravity_manager-1.0.0/src/antigravity_manager/utils.py +19 -0
  25. antigravity_manager-1.0.0/src/antigravity_manager.egg-info/PKG-INFO +81 -0
  26. antigravity_manager-1.0.0/src/antigravity_manager.egg-info/SOURCES.txt +50 -0
  27. antigravity_manager-1.0.0/src/antigravity_manager.egg-info/dependency_links.txt +1 -0
  28. antigravity_manager-1.0.0/src/antigravity_manager.egg-info/entry_points.txt +3 -0
  29. antigravity_manager-1.0.0/src/antigravity_manager.egg-info/requires.txt +19 -0
  30. antigravity_manager-1.0.0/src/antigravity_manager.egg-info/top_level.txt +1 -0
  31. antigravity_manager-1.0.0/tests/test_backup_restore.py +283 -0
  32. antigravity_manager-1.0.0/tests/test_cli.py +99 -0
  33. antigravity_manager-1.0.0/tests/test_cli_commands.py +129 -0
  34. antigravity_manager-1.0.0/tests/test_cooldown.py +42 -0
  35. antigravity_manager-1.0.0/tests/test_cooldown2.py +121 -0
  36. antigravity_manager-1.0.0/tests/test_credentials.py +80 -0
  37. antigravity_manager-1.0.0/tests/test_doctor.py +18 -0
  38. antigravity_manager-1.0.0/tests/test_doctor_2.py +34 -0
  39. antigravity_manager-1.0.0/tests/test_list_backups.py +133 -0
  40. antigravity_manager-1.0.0/tests/test_profile.py +24 -0
  41. antigravity_manager-1.0.0/tests/test_prune.py +80 -0
  42. antigravity_manager-1.0.0/tests/test_prune_backups.py +11 -0
  43. antigravity_manager-1.0.0/tests/test_purge.py +64 -0
  44. antigravity_manager-1.0.0/tests/test_registry.py +16 -0
  45. antigravity_manager-1.0.0/tests/test_remove.py +55 -0
  46. antigravity_manager-1.0.0/tests/test_status.py +78 -0
  47. antigravity_manager-1.0.0/tests/test_status_2.py +21 -0
  48. antigravity_manager-1.0.0/tests/test_status_3.py +30 -0
  49. antigravity_manager-1.0.0/tests/test_status_4.py +39 -0
  50. antigravity_manager-1.0.0/tests/test_sync.py +59 -0
  51. antigravity_manager-1.0.0/tests/test_ui_rendering.py +185 -0
  52. antigravity_manager-1.0.0/tests/test_utils.py +15 -0
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: antigravity-manager
3
+ Version: 1.0.0
4
+ Summary: Account backup, restore, cooldown, and orchestration manager for Antigravity CLI
5
+ Author-email: Dhruv <dhruv13x@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/dhruv13x/antigravity-manager
8
+ Project-URL: Repository, https://github.com/dhruv13x/antigravity-manager
9
+ Project-URL: Issues, https://github.com/dhruv13x/antigravity-manager/issues
10
+ Keywords: antigravity,gemini,backup,cli,automation,orchestration,quota
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Environment :: Console
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: pyfakefs>=6.2.0
19
+ Requires-Dist: pytest-asyncio>=1.3.0
20
+ Requires-Dist: pytest-cov>=7.1.0
21
+ Requires-Dist: pytest-mock>=3.15.1
22
+ Requires-Dist: pytest-timeout>=2.4.0
23
+ Requires-Dist: rich>=13.0.0
24
+ Requires-Dist: b2sdk>=2.1.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
27
+ Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
28
+ Requires-Dist: pytest-timeout>=2.2.0; extra == "dev"
29
+ Requires-Dist: pytest-json-report>=1.5.0; extra == "dev"
30
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
31
+ Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
32
+ Requires-Dist: pyfakefs>=5.0.0; extra == "dev"
33
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
34
+ Requires-Dist: black>=24.3.0; extra == "dev"
35
+ Requires-Dist: mypy>=1.11.0; extra == "dev"
36
+
37
+ # antigravity-manager
38
+
39
+ Account backup, restore, cooldown, and status manager for Antigravity CLI.
40
+ ## Commands
41
+
42
+ ```bash
43
+ agm # Defaults to 'cooldown'
44
+ agm -s # Shortcut for 'status'
45
+ agm -c # Shortcut for 'cooldown'
46
+ agm -v # Show version
47
+ agm -h # Show help
48
+ ```
49
+
50
+ uv run agm backup
51
+ uv run agm backup --auth-only
52
+ uv run agm backup --decision-model "Gemini 3.5 Flash"
53
+ uv run agm restore --email you@example.com
54
+ uv run agm restore --from-archive ./backup.tar.gz --full
55
+ uv run agm use you@example.com
56
+ uv run agm cooldown
57
+ uv run agm recommend
58
+ uv run agm list-backups
59
+ uv run agm doctor
60
+ ```
61
+
62
+ Default paths:
63
+
64
+ - Manager state: `~/.antigravity-manager`
65
+ - Backups: `~/.antigravity-manager/backups`
66
+ - Antigravity CLI state: `~/.gemini/antigravity-cli`
67
+
68
+ `agm restore` is auth-only by default. Use `--full` only when you want to replace
69
+ the whole Antigravity CLI state directory.
70
+
71
+ `agm` is scoped to the Antigravity CLI state directory and does not back up,
72
+ restore, or mutate files directly under `~/.gemini`.
73
+
74
+ Backups and recommendations use `Gemini 3.5 Flash` as the default decision
75
+ model. If Antigravity exposes a reset time for that model, the backup filename is
76
+ anchored to that reset time. If the model is available and `/usage` does not show
77
+ a reset time, the filename uses an explicit 5-hour estimate and records that
78
+ source in metadata.
79
+
80
+ Before restore/use, `agm` writes an account-named current-state copy under
81
+ `~/.antigravity-manager/safety_backups`.
@@ -0,0 +1,45 @@
1
+ # antigravity-manager
2
+
3
+ Account backup, restore, cooldown, and status manager for Antigravity CLI.
4
+ ## Commands
5
+
6
+ ```bash
7
+ agm # Defaults to 'cooldown'
8
+ agm -s # Shortcut for 'status'
9
+ agm -c # Shortcut for 'cooldown'
10
+ agm -v # Show version
11
+ agm -h # Show help
12
+ ```
13
+
14
+ uv run agm backup
15
+ uv run agm backup --auth-only
16
+ uv run agm backup --decision-model "Gemini 3.5 Flash"
17
+ uv run agm restore --email you@example.com
18
+ uv run agm restore --from-archive ./backup.tar.gz --full
19
+ uv run agm use you@example.com
20
+ uv run agm cooldown
21
+ uv run agm recommend
22
+ uv run agm list-backups
23
+ uv run agm doctor
24
+ ```
25
+
26
+ Default paths:
27
+
28
+ - Manager state: `~/.antigravity-manager`
29
+ - Backups: `~/.antigravity-manager/backups`
30
+ - Antigravity CLI state: `~/.gemini/antigravity-cli`
31
+
32
+ `agm restore` is auth-only by default. Use `--full` only when you want to replace
33
+ the whole Antigravity CLI state directory.
34
+
35
+ `agm` is scoped to the Antigravity CLI state directory and does not back up,
36
+ restore, or mutate files directly under `~/.gemini`.
37
+
38
+ Backups and recommendations use `Gemini 3.5 Flash` as the default decision
39
+ model. If Antigravity exposes a reset time for that model, the backup filename is
40
+ anchored to that reset time. If the model is available and `/usage` does not show
41
+ a reset time, the filename uses an explicit 5-hour estimate and records that
42
+ source in metadata.
43
+
44
+ Before restore/use, `agm` writes an account-named current-state copy under
45
+ `~/.antigravity-manager/safety_backups`.
@@ -0,0 +1,123 @@
1
+ [project]
2
+ name = "antigravity-manager"
3
+ version = "1.0.0"
4
+ description = "Account backup, restore, cooldown, and orchestration manager for Antigravity CLI"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+
9
+ authors = [
10
+ { name = "Dhruv", email = "dhruv13x@gmail.com" }
11
+ ]
12
+
13
+ dependencies = [
14
+ "pyfakefs>=6.2.0",
15
+ "pytest-asyncio>=1.3.0",
16
+ "pytest-cov>=7.1.0",
17
+ "pytest-mock>=3.15.1",
18
+ "pytest-timeout>=2.4.0",
19
+ "rich>=13.0.0",
20
+ "b2sdk>=2.1.0",
21
+ ]
22
+
23
+ keywords = [
24
+ "antigravity",
25
+ "gemini",
26
+ "backup",
27
+ "cli",
28
+ "automation",
29
+ "orchestration",
30
+ "quota",
31
+ ]
32
+
33
+ classifiers = [
34
+ "Programming Language :: Python :: 3",
35
+ "Programming Language :: Python :: 3.12",
36
+ "License :: OSI Approved :: MIT License",
37
+ "Operating System :: OS Independent",
38
+ "Environment :: Console",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/dhruv13x/antigravity-manager"
43
+ Repository = "https://github.com/dhruv13x/antigravity-manager"
44
+ Issues = "https://github.com/dhruv13x/antigravity-manager/issues"
45
+
46
+ [project.scripts]
47
+ agm = "antigravity_manager.cli:main"
48
+ antigravity-manager = "antigravity_manager.cli:main"
49
+
50
+ [build-system]
51
+ requires = ["setuptools>=68", "wheel"]
52
+ build-backend = "setuptools.build_meta"
53
+
54
+ [project.optional-dependencies]
55
+ dev = [
56
+ "pytest>=8.0.0",
57
+ "pytest-cov>=5.0.0",
58
+ "pytest-timeout>=2.2.0",
59
+ "pytest-json-report>=1.5.0",
60
+ "pytest-asyncio>=0.23.0",
61
+ "pytest-mock>=3.10.0",
62
+ "pyfakefs>=5.0.0",
63
+ "ruff>=0.6.0",
64
+ "black>=24.3.0",
65
+ "mypy>=1.11.0",
66
+ ]
67
+
68
+ [tool.setuptools]
69
+ package-dir = {"" = "src"}
70
+
71
+ [tool.setuptools.packages.find]
72
+ where = ["src"]
73
+
74
+ [tool.pytest.ini_options]
75
+ pythonpath = ["src"]
76
+ testpaths = ["tests"]
77
+
78
+ addopts = [
79
+ "-v",
80
+ "-ra",
81
+ "--strict-config",
82
+ "--strict-markers",
83
+ "--tb=short",
84
+ "--showlocals",
85
+ "--import-mode=importlib",
86
+ "--timeout=10",
87
+ "--durations=10",
88
+ "--cov=src/antigravity_manager",
89
+ "--cov-report=term-missing:skip-covered",
90
+ "--cov-report=html",
91
+ "--cov-report=xml",
92
+ "--cov-fail-under=50",
93
+ ]
94
+
95
+ log_cli = true
96
+ log_cli_level = "INFO"
97
+
98
+ [tool.coverage.run]
99
+ branch = true
100
+ source = ["src/antigravity_manager"]
101
+
102
+ [tool.coverage.report]
103
+ show_missing = true
104
+ skip_covered = true
105
+ fail_under = 50
106
+ precision = 2
107
+
108
+ [tool.ruff]
109
+ line-length = 100
110
+ target-version = "py312"
111
+
112
+ [tool.ruff.lint]
113
+ select = ["E", "F", "I", "UP", "B"]
114
+ ignore = ["E501"]
115
+
116
+ [tool.black]
117
+ line-length = 100
118
+ target-version = ["py312"]
119
+
120
+ [tool.mypy]
121
+ python_version = "3.12"
122
+ strict = true
123
+ ignore_missing_imports = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,315 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import tarfile
5
+ import tempfile
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .config import (
11
+ ANTIGRAVITY_AUTH_FILES,
12
+ DEFAULT_DECISION_MODEL,
13
+ EXCLUDED_TOP_LEVEL_NAMES,
14
+ )
15
+ from .registry import update_registry_from_status
16
+ from .status import LiveStatus, capture_tmux_status_text, parse_live_status_text, status_to_dict
17
+ from .ui import RenderableType
18
+ from .utils import build_archive_name, isoformat_local
19
+
20
+ ESTIMATED_MODEL_RESET_HOURS = 5
21
+
22
+
23
+ def find_decision_model(
24
+ status: LiveStatus, model_pattern: str = DEFAULT_DECISION_MODEL
25
+ ) -> Any | None:
26
+ pattern = model_pattern.lower()
27
+ matches = [model for model in status.models if pattern in model.model_name.lower()]
28
+ if not matches:
29
+ return None
30
+ high = [model for model in matches if "(high)" in model.model_name.lower()]
31
+ return high[0] if high else matches[0]
32
+
33
+
34
+ def resolve_backup_anchor(
35
+ status: LiveStatus, model_pattern: str = DEFAULT_DECISION_MODEL
36
+ ) -> tuple[datetime, str, str | None]:
37
+ model = find_decision_model(status, model_pattern)
38
+ if model and model.refresh_at is not None:
39
+ return model.refresh_at, "decision_model_refresh_at", model.model_name
40
+ if model and model.is_available:
41
+ return (
42
+ status.captured_at + timedelta(hours=ESTIMATED_MODEL_RESET_HOURS),
43
+ "estimated_5h_decision_model_available",
44
+ model.model_name,
45
+ )
46
+
47
+ refreshes = [item.refresh_at for item in status.models if item.refresh_at is not None]
48
+ if refreshes:
49
+ return max(refreshes), "latest_model_refresh_at", model.model_name if model else None
50
+
51
+ return (
52
+ status.captured_at + timedelta(hours=ESTIMATED_MODEL_RESET_HOURS),
53
+ "estimated_5h_no_model_reset_in_usage",
54
+ model.model_name if model else None,
55
+ )
56
+
57
+
58
+ def read_active_email(antigravity_home: Path) -> str | None:
59
+ path = antigravity_home / "google_accounts.json"
60
+ try:
61
+ data = json.loads(path.read_text(encoding="utf-8"))
62
+ except Exception:
63
+ return None
64
+ active = data.get("active")
65
+ return active.strip() if isinstance(active, str) and active.strip() else None
66
+
67
+
68
+ def fallback_status(email: str | None) -> LiveStatus:
69
+ from .status import ModelQuotaStatus
70
+
71
+ now = datetime.now().astimezone()
72
+ reset_at = now + timedelta(hours=ESTIMATED_MODEL_RESET_HOURS)
73
+ return LiveStatus(
74
+ email=email or "unknown",
75
+ plan="unknown",
76
+ is_pro=False,
77
+ captured_at=now,
78
+ models=(
79
+ ModelQuotaStatus(
80
+ model_name="unknown",
81
+ quota_percent_left=None,
82
+ refresh_in_text="Status capture bypassed; estimated 5h cooldown",
83
+ refresh_at=reset_at,
84
+ is_available=False,
85
+ ),
86
+ ),
87
+ )
88
+
89
+
90
+ def get_status_for_backup(args: Any) -> LiveStatus:
91
+ if getattr(args, "without_status_check", False):
92
+ return fallback_status(read_active_email(Path(args.source_dir).expanduser()))
93
+ if getattr(args, "status_file", None):
94
+ text = Path(args.status_file).expanduser().read_text(encoding="utf-8")
95
+ else:
96
+ text = capture_tmux_status_text(
97
+ session_name=getattr(args, "tmux_session_name", None),
98
+ agy_command=getattr(args, "agy_command", "agy"),
99
+ cols=getattr(args, "tmux_cols", 140),
100
+ rows=getattr(args, "tmux_rows", 45),
101
+ startup_timeout_seconds=getattr(args, "startup_timeout_seconds", 30.0),
102
+ usage_timeout_seconds=getattr(args, "usage_timeout_seconds", 30.0),
103
+ )
104
+ return parse_live_status_text(text)
105
+
106
+
107
+ def build_backup_metadata(
108
+ status: LiveStatus,
109
+ archive_path: Path,
110
+ *,
111
+ source_antigravity_home: Path,
112
+ backup_mode: str,
113
+ include_bin: bool,
114
+ include_logs: bool,
115
+ decision_model: str,
116
+ backup_anchor_at: datetime,
117
+ backup_anchor_source: str,
118
+ backup_anchor_model: str | None,
119
+ ) -> dict[str, Any]:
120
+ next_refreshes = [model.refresh_at for model in status.models if model.refresh_at is not None]
121
+ next_available_at = (
122
+ max(next_refreshes).isoformat() if next_refreshes else status.captured_at.isoformat()
123
+ )
124
+ return {
125
+ "schema_version": 1,
126
+ "product": "antigravity",
127
+ "email": status.email,
128
+ "plan": status.plan,
129
+ "created_at": isoformat_local(datetime.now().astimezone()),
130
+ "captured_at": isoformat_local(status.captured_at),
131
+ "next_available_at": next_available_at,
132
+ "decision_model": decision_model,
133
+ "backup_anchor_at": isoformat_local(backup_anchor_at),
134
+ "backup_anchor_source": backup_anchor_source,
135
+ "backup_anchor_model": backup_anchor_model,
136
+ "archive_name": archive_path.name,
137
+ "archive_path": str(archive_path),
138
+ "source_antigravity_home": str(source_antigravity_home),
139
+ "backup_mode": backup_mode,
140
+ "include_bin": include_bin,
141
+ "include_logs": include_logs,
142
+ "status": status_to_dict(status),
143
+ }
144
+
145
+
146
+ def iter_antigravity_entries(
147
+ source_dir: Path, *, auth_only: bool, include_bin: bool, include_logs: bool
148
+ ) -> list[Path]:
149
+ if auth_only:
150
+ return [
151
+ source_dir / name for name in ANTIGRAVITY_AUTH_FILES if (source_dir / name).exists()
152
+ ]
153
+
154
+ entries = []
155
+ for path in sorted(source_dir.iterdir(), key=lambda item: item.name):
156
+ if path.name == "bin" and not include_bin:
157
+ continue
158
+ if path.name == "log" and not include_logs:
159
+ continue
160
+ if path.name in EXCLUDED_TOP_LEVEL_NAMES and path.name != "log":
161
+ continue
162
+ entries.append(path)
163
+ return entries
164
+
165
+
166
+ def create_backup_archive(
167
+ *,
168
+ archive_path: Path,
169
+ metadata_path: Path,
170
+ metadata: dict[str, Any],
171
+ antigravity_home: Path,
172
+ auth_only: bool,
173
+ include_bin: bool,
174
+ include_logs: bool,
175
+ ) -> None:
176
+ archive_path.parent.mkdir(parents=True, exist_ok=True)
177
+ with tempfile.TemporaryDirectory(prefix="agm-backup-") as temp_dir_str:
178
+ temp_metadata_path = Path(temp_dir_str) / metadata_path.name
179
+ temp_metadata_path.write_text(
180
+ json.dumps(metadata, indent=2, sort_keys=True), encoding="utf-8"
181
+ )
182
+
183
+ with tarfile.open(archive_path, "w:gz") as tar:
184
+ for path in iter_antigravity_entries(
185
+ antigravity_home,
186
+ auth_only=auth_only,
187
+ include_bin=include_bin,
188
+ include_logs=include_logs,
189
+ ):
190
+ tar.add(path, arcname=f"antigravity-cli/{path.relative_to(antigravity_home)}", recursive=True)
191
+ tar.add(temp_metadata_path, arcname=temp_metadata_path.name, recursive=False)
192
+
193
+
194
+ def perform_backup(args: Any) -> tuple[Path, Path, dict[str, Any]]:
195
+ antigravity_home = Path(args.source_dir).expanduser()
196
+ if not antigravity_home.is_dir():
197
+ raise FileNotFoundError(f"Antigravity directory does not exist: {antigravity_home}")
198
+
199
+ status = get_status_for_backup(args)
200
+ backup_dir = Path(args.backup_dir).expanduser()
201
+ decision_model = getattr(args, "decision_model", DEFAULT_DECISION_MODEL)
202
+ backup_anchor_at, backup_anchor_source, backup_anchor_model = resolve_backup_anchor(
203
+ status, decision_model
204
+ )
205
+ archive_path = backup_dir / build_archive_name(backup_anchor_at, status.email)
206
+ metadata_path = archive_path.with_name(archive_path.name.replace(".tar.gz", ".metadata.json"))
207
+ metadata = build_backup_metadata(
208
+ status,
209
+ archive_path,
210
+ source_antigravity_home=antigravity_home,
211
+ backup_mode="auth-only" if getattr(args, "auth_only", False) else "full",
212
+ include_bin=getattr(args, "include_bin", False),
213
+ include_logs=getattr(args, "include_logs", False),
214
+ decision_model=decision_model,
215
+ backup_anchor_at=backup_anchor_at,
216
+ backup_anchor_source=backup_anchor_source,
217
+ backup_anchor_model=backup_anchor_model,
218
+ )
219
+
220
+ if getattr(args, "dry_run", False):
221
+ return archive_path, metadata_path, metadata
222
+
223
+ if archive_path.exists() and not getattr(args, "force", False):
224
+ raise FileExistsError(f"Archive already exists: {archive_path}. Use --force to overwrite.")
225
+ if archive_path.exists():
226
+ archive_path.unlink()
227
+ if metadata_path.exists():
228
+ metadata_path.unlink()
229
+
230
+ create_backup_archive(
231
+ archive_path=archive_path,
232
+ metadata_path=metadata_path,
233
+ metadata=metadata,
234
+ antigravity_home=antigravity_home,
235
+ auth_only=getattr(args, "auth_only", False),
236
+ include_bin=getattr(args, "include_bin", False),
237
+ include_logs=getattr(args, "include_logs", False),
238
+ )
239
+
240
+ if getattr(args, "encrypt", False):
241
+ import getpass
242
+ import subprocess
243
+
244
+ import antigravity_manager.ui
245
+
246
+ antigravity_manager.ui.console.print(f"Encrypting archive: {archive_path} -> .gpg")
247
+ import os
248
+
249
+ passphrase = os.environ.get("AGM_BACKUP_PASSWORD")
250
+ if not passphrase:
251
+ passphrase = getpass.getpass("Enter passphrase for backup encryption: ")
252
+
253
+ if not passphrase:
254
+ raise ValueError("Encryption requested but no passphrase provided.")
255
+
256
+ encrypted_path = archive_path.with_suffix(archive_path.suffix + ".gpg")
257
+ gpg_cmd = [
258
+ "gpg",
259
+ "--symmetric",
260
+ "--cipher-algo",
261
+ "AES256",
262
+ "--passphrase-fd",
263
+ "0",
264
+ "--batch",
265
+ "--yes",
266
+ "--output",
267
+ str(encrypted_path),
268
+ str(archive_path),
269
+ ]
270
+ try:
271
+ subprocess.run(gpg_cmd, input=passphrase.encode(), check=True)
272
+ archive_path.unlink()
273
+ archive_path = encrypted_path
274
+ metadata["archive_name"] = archive_path.name
275
+ metadata["archive_path"] = str(archive_path)
276
+ metadata["encrypted"] = True
277
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
278
+ raise RuntimeError(f"GPG encryption failed: {e}") from e
279
+
280
+ metadata_path.write_text(json.dumps(metadata, indent=2, sort_keys=True), encoding="utf-8")
281
+
282
+ latest_path = backup_dir / f"{status.email}-latest-antigravity.tar.gz"
283
+ if latest_path.exists() or latest_path.is_symlink():
284
+ latest_path.unlink()
285
+ latest_path.symlink_to(archive_path.name)
286
+ update_registry_from_status(status)
287
+ return archive_path, metadata_path, metadata
288
+
289
+
290
+ def backup_result_to_text(
291
+ archive_path: Path, metadata_path: Path, metadata: dict[str, Any], *, dry_run: bool
292
+ ) -> RenderableType:
293
+ from .ui import Panel, Table
294
+
295
+ title = "[warning]Backup Result (Dry Run)[/]" if dry_run else "[success]Backup Created[/]"
296
+ border_style = "warning" if dry_run else "success"
297
+
298
+ table = Table.grid(padding=(0, 2))
299
+ table.add_column(style="bold cyan", justify="right")
300
+ table.add_column(style="white")
301
+
302
+ table.add_row("Email:", str(metadata.get("email", "unknown")))
303
+ table.add_row("Plan:", str(metadata.get("plan", "unknown")))
304
+ table.add_row("Archive:", str(archive_path))
305
+ table.add_row("Metadata:", str(metadata_path))
306
+ table.add_row("Next Available:", str(metadata.get("next_available_at", "unknown")))
307
+
308
+ anchor_src = metadata.get("backup_anchor_source", "unknown")
309
+ table.add_row(
310
+ "Anchor:",
311
+ f"{metadata.get('backup_anchor_at', 'unknown')} [dim]({anchor_src})[/dim]",
312
+ )
313
+ table.add_row("Mode:", str(metadata.get("backup_mode", "unknown")))
314
+
315
+ return Panel(table, title=title, border_style=border_style, expand=False)
@@ -0,0 +1,55 @@
1
+ import math
2
+
3
+ from rich.console import Console
4
+ from rich.text import Text
5
+
6
+ console = Console()
7
+
8
+
9
+ def lerp(a, b, t):
10
+ return a + (b - a) * t
11
+
12
+
13
+ def blend(c1, c2, t):
14
+ t = t**1.47
15
+ t = 0.82 * t + 0.08 * math.sin(3.2 * t)
16
+ r = int(lerp(c1[0], c2[0], t))
17
+ g = int(lerp(c1[1], c2[1], t))
18
+ b = int(lerp(c1[2], c2[2], t))
19
+ return f"#{r:02x}{g:02x}{b:02x}"
20
+
21
+
22
+ def print_logo():
23
+ logo = r"""
24
+ _ _ _ _____ ___ ____ ____ _ __ _____ _______ __
25
+ / \ | \| |_ _|_ _/ ___| _ \ / \ \ \ / /_ _|_ _\ \ / /
26
+ / _ \| . ` | | | | | | _| |_) | / _ \ \ \ / / | | | | \ V /
27
+ / ___ \ |\ | | | | | |___| _ < / ___ \ \ V / | | | | | |
28
+ /_/ \_\_| \_| |_| |___\____|_| \_\/_/ \_\_/ |___| |_| |_|
29
+ M A N A G E R
30
+ """.strip().split("\n")
31
+
32
+ palette = [
33
+ (0x2E, 0x7B, 0xEA),
34
+ (0x6C, 0x5B, 0xD8),
35
+ (0xB6, 0x6D, 0xB9),
36
+ (0xE8, 0x8A, 0xA6),
37
+ (0xFF, 0xB6, 0xC1),
38
+ ]
39
+
40
+ H = len(logo)
41
+ for i, line in enumerate(logo):
42
+ tline = Text()
43
+ W = len(line)
44
+ for j, ch in enumerate(line):
45
+ raw = i * 0.72 + j * 0.44
46
+ t = raw / (H * 0.72 + W * 0.44)
47
+ seg = t * (len(palette) - 1)
48
+ idx = int(seg)
49
+ t2 = seg - idx
50
+ c1 = palette[idx]
51
+ c2 = palette[min(idx + 1, len(palette) - 1)]
52
+ tline.append(ch, style=blend(c1, c2, t2))
53
+ console.print(tline)
54
+
55
+ console.print("[dim]CLI interface for managing Antigravity accounts and backups.[/dim]\n")