clighost 0.0.0-stage → 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Issam El Bani (VELTEIN)
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.
package/README.md CHANGED
@@ -1,3 +1,175 @@
1
- # Temporary Holding Version
1
+ # CliGhost
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **One terminal. Multiple identities.**
4
+
5
+ Created by **Issam El Bani** at **[VELTEIN](https://veltein.com)**
6
+
7
+ [![npm](https://img.shields.io/npm/v/clighost?color=39ff88&label=clighost)](https://www.npmjs.com/package/clighost)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-39ff88)](https://github.com/ssassam/clighost/blob/main/LICENSE)
9
+
10
+ One command-line interface for **Codex** and **Antigravity CLI (`agy`)** accounts.
11
+ List accounts and remaining quota, add OAuth logins, switch accounts, and launch
12
+ sessions through the existing account-rotation tools.
13
+
14
+ This is an independent orchestration CLI, not a new OAuth implementation or
15
+ request proxy. It delegates to [codex-multi-auth](https://github.com/ndycode/codex-multi-auth)
16
+ and [antigravity-swap](https://github.com/hrmasss/antigravity-swap). It does not
17
+ read, copy, export, or upload OAuth credential files.
18
+
19
+ ## Install
20
+
21
+ ### From npm
22
+
23
+ Requires Node.js 18+ and Python 3.10+. The two account backends have their own
24
+ requirements listed below. CliGhost bundles its Python core; no separate pip
25
+ installation of CliGhost is needed.
26
+
27
+ ```sh
28
+ npm install -g clighost
29
+ clighost --help
30
+ ```
31
+
32
+ Or run without a global installation:
33
+
34
+ ```sh
35
+ npx clighost status
36
+ ```
37
+
38
+ If Python is not on PATH, set `CLIGHOST_PYTHON` to the Python 3.10+ executable.
39
+
40
+ ### Provider backends
41
+
42
+ Install the official Codex and Antigravity CLIs, then their account backends:
43
+
44
+ ```sh
45
+ # Node.js >=22.19 is required by codex-multi-auth.
46
+ npm install -g codex-multi-auth
47
+
48
+ # Python >=3.12 is required by antigravity-swap.
49
+ pipx install antigravity-swap
50
+
51
+ clighost doctor
52
+ ```
53
+
54
+ ### From source with pipx
55
+
56
+ ```sh
57
+ git clone https://github.com/ssassam/clighost.git
58
+ pipx install ./clighost
59
+ clighost doctor
60
+ ```
61
+
62
+ The earlier `aam` command remains a compatibility alias in Python installations.
63
+ The npm package installs the `clighost` command.
64
+
65
+ The upstream projects also offer other installation methods. Existing installations
66
+ work: `clighost` resolves commands from PATH, including privately installed runtimes.
67
+
68
+ ## Everyday commands
69
+
70
+ ```sh
71
+ clighost status # Both providers, cached quota
72
+ clighost status codex
73
+ clighost status agy --live # Refresh Antigravity quota
74
+ clighost status --json # Normalized JSON; no credential fields
75
+ clighost check codex # Live authentication + inference/quota check
76
+ clighost check agy # Refresh quota; renew login when needed
77
+ clighost switch codex 2 # Pin Codex account 2
78
+ clighost switch agy 2 # Change Antigravity's default account
79
+ clighost run codex # mcodex with automatic rotation
80
+ clighost run agy # aswap run with quota failover
81
+ clighost run codex -- resume --last
82
+ clighost run codex -- exec "Explain this project"
83
+ clighost run agy -- --continue
84
+ clighost run agy -- -p "Explain this project"
85
+ ```
86
+
87
+ Numbers in `clighost status` are one-based and match the `switch` commands.
88
+ Quota percentages mean **remaining**, including Antigravity reports whose
89
+ upstream percentages mean used. Missing quota stays unknown.
90
+ `status --live --json` writes live-check progress to stderr and JSON to stdout.
91
+ Codex live checks consume some subscription quota.
92
+
93
+ ## Add accounts
94
+
95
+ ```sh
96
+ clighost login codex # Device OAuth; repeat for each account
97
+ clighost login agy # Opens agy; sign in, then exit to save
98
+ clighost add agy # Save the account already signed in to agy
99
+ ```
100
+
101
+ For Antigravity, select the intended Google account in agy's own sign-in UI.
102
+ If it is already signed in, use its account/sign-in controls to change accounts
103
+ before saving. Adding the same account refreshes its stored credentials rather
104
+ than creating another account. Google and ChatGPT accounts are separate pools;
105
+ logging into Codex does not log into Antigravity.
106
+
107
+ ## Switching and continuity
108
+
109
+ Codex rotation works inside sessions launched with `clighost run codex`. Calling plain
110
+ `codex` does not enable that wrapper. `clighost switch codex N` pins the selected account;
111
+ use `codex-ma unpin` to return to automatic selection.
112
+
113
+ Antigravity keeps its credentials in memory. `clighost switch agy N` affects new sessions.
114
+ `clighost run agy` uses aswap's quota-failover behavior: stop the session, copy its
115
+ conversation to the next account, and restart with a continuation prompt.
116
+ Saved project files remain in the same working directory. An unfinished tool
117
+ action may be interrupted; this is not a guarantee of exact execution continuity.
118
+ At least two healthy saved accounts are required to test failover.
119
+
120
+ Automatic health-check schedules remain the backend's settings. For Codex:
121
+
122
+ ```sh
123
+ codex-ma account auto-prime 1 on
124
+ codex-ma account auto-prime 2 on
125
+ ```
126
+
127
+ Those checks run every 15 minutes while the rotation router is running.
128
+ The manager does not install background daemons, shell aliases, Telegram bots,
129
+ or scheduled jobs. Use upstream commands for advanced provider-specific settings.
130
+
131
+ ## Another device
132
+
133
+ Clone this repository, install both backends, then sign in on that device. Accounts
134
+ and backend settings do not sync through Git. OAuth snapshots are sensitive and
135
+ must never be committed. This repository contains source code only.
136
+
137
+ ## Development
138
+
139
+ ```sh
140
+ python3 -m venv .venv
141
+ .venv/bin/pip install -e .
142
+ .venv/bin/python -m unittest discover -s tests -v
143
+ ```
144
+
145
+ For the npm launcher and package contents:
146
+
147
+ ```sh
148
+ npm test
149
+ npm pack --dry-run
150
+ ```
151
+
152
+ Tests mock backend processes and do not make billable requests or change logins.
153
+ Runtime code has no third-party Python dependencies. Backend JSON schema changes
154
+ may require adapter updates. Verified locally with codex-multi-auth 2.24.0 and
155
+ antigravity-swap 0.3.1; automatic two-account Antigravity failover has not been
156
+ verified locally.
157
+
158
+ ## Support the creator
159
+
160
+ [![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-Support_VELTEIN-FFDD00?logo=buymeacoffee&logoColor=000000)](https://buymeacoffee.com/veltein)
161
+
162
+ If CliGhost helps your workflow, you can support its development on
163
+ [Buy Me a Coffee](https://buymeacoffee.com/veltein).
164
+
165
+ ## Creator and credits
166
+
167
+ CliGhost is created and maintained by **Issam El Bani / [VELTEIN](https://veltein.com)**.
168
+ Follow [@ssassam on GitHub](https://github.com/ssassam).
169
+
170
+ Account authentication and rotation are provided by the independent upstream
171
+ projects [codex-multi-auth by ndycode](https://github.com/ndycode/codex-multi-auth)
172
+ and [antigravity-swap by hrmasss](https://github.com/hrmasss/antigravity-swap).
173
+ Credit for those backends belongs to their authors.
174
+
175
+ MIT licensed. Not affiliated with OpenAI or Google.
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // npm distributes the same Python core as the pipx install. No install hooks,
5
+ // credential copying, or runtime downloads are needed.
6
+ const { spawn, spawnSync } = require('node:child_process');
7
+ const path = require('node:path');
8
+ const os = require('node:os');
9
+
10
+ function findPython(env = process.env, probe = spawnSync, platform = process.platform) {
11
+ const candidates = env.CLIGHOST_PYTHON
12
+ ? [[env.CLIGHOST_PYTHON]]
13
+ : platform === 'win32'
14
+ ? [['py', '-3'], ['python3'], ['python']]
15
+ : [['python3'], ['python']];
16
+ for (const [command, ...prefix] of candidates) {
17
+ const result = probe(command, [
18
+ ...prefix, '-c', 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)',
19
+ ], { stdio: 'ignore', timeout: 5000, env });
20
+ if (!result.error && result.status === 0) return { command, prefix };
21
+ }
22
+ throw new Error(env.CLIGHOST_PYTHON
23
+ ? 'CLIGHOST_PYTHON must point to a working Python 3.10+ executable.'
24
+ : 'Python 3.10+ is required. Install Python, or set CLIGHOST_PYTHON to its executable.');
25
+ }
26
+
27
+ function launchOptions(interpreter, args, env = process.env) {
28
+ const source = path.resolve(__dirname, '..', 'src');
29
+ return {
30
+ command: interpreter.command,
31
+ args: [...interpreter.prefix, '-m', 'agent_accounts.cli', ...args],
32
+ options: {
33
+ stdio: 'inherit',
34
+ env: { ...env, PYTHONPATH: source + (env.PYTHONPATH ? path.delimiter + env.PYTHONPATH : '') },
35
+ },
36
+ };
37
+ }
38
+
39
+ function main() {
40
+ let launch;
41
+ try {
42
+ launch = launchOptions(findPython(), process.argv.slice(2));
43
+ } catch (error) {
44
+ console.error(`clighost: ${error.message}`);
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ const child = spawn(launch.command, launch.args, launch.options);
49
+ // Interactive terminals deliver Ctrl+C to the foreground process group.
50
+ // Forward external termination so a killed npm launcher does not leave an agent.
51
+ const terminate = () => child.kill('SIGTERM');
52
+ const interrupt = () => {}; // The child receives the terminal's SIGINT directly.
53
+ process.on('SIGTERM', terminate);
54
+ process.on('SIGINT', interrupt);
55
+ child.on('error', (error) => {
56
+ console.error(`clighost: could not start Python (${error.code || 'unknown error'}).`);
57
+ process.exitCode = 1;
58
+ });
59
+ child.on('close', (code, signal) => {
60
+ process.removeListener('SIGTERM', terminate);
61
+ process.removeListener('SIGINT', interrupt);
62
+ process.exitCode = code === null ? 128 + (os.constants.signals[signal] || 1) : code;
63
+ });
64
+ }
65
+
66
+ module.exports = { findPython, launchOptions };
67
+ if (require.main === module) main();
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "clighost",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "CliGhost by VELTEIN: one CLI for Codex and Antigravity OAuth accounts, quotas, switching, and failover launchers.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Issam El Bani (VELTEIN)",
8
+ "url": "https://veltein.com"
9
+ },
10
+ "homepage": "https://github.com/ssassam/clighost#readme",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ssassam/clighost.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/ssassam/clighost/issues"
17
+ },
18
+ "bin": {
19
+ "clighost": "bin/clighost.cjs"
20
+ },
21
+ "files": [
22
+ "bin/clighost.cjs",
23
+ "src/agent_accounts/*.py",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org/"
33
+ },
34
+ "keywords": [
35
+ "clighost",
36
+ "codex",
37
+ "antigravity",
38
+ "agy",
39
+ "oauth",
40
+ "multi-account",
41
+ "account-switcher",
42
+ "quota",
43
+ "cli"
44
+ ],
45
+ "scripts": {
46
+ "test": "node --test tests/npm-launcher.test.cjs",
47
+ "prepack": "npm test"
48
+ },
49
+ "funding": {
50
+ "type": "buymeacoffee",
51
+ "url": "https://buymeacoffee.com/veltein"
52
+ }
53
+ }
@@ -0,0 +1,3 @@
1
+ """Unified account management without storing OAuth credentials."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,152 @@
1
+ """Invoke the existing account managers and normalize their public reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ from typing import Any
11
+
12
+
13
+ class BackendError(Exception):
14
+ """An actionable failure from an installed account manager."""
15
+
16
+
17
+ COMMANDS = {"codex": "codex-ma", "agy": "aswap"}
18
+ INSTALL = {
19
+ "codex": "npm install -g codex-multi-auth (requires Node.js >=22.19 and Codex CLI)",
20
+ "agy": "pipx install antigravity-swap (requires Python >=3.12 and agy)",
21
+ }
22
+
23
+
24
+ def executable(provider: str) -> str:
25
+ command = COMMANDS[provider]
26
+ path = shutil.which(command)
27
+ if not path:
28
+ raise BackendError(f"Missing {command}. Install it with: {INSTALL[provider]}")
29
+ return path
30
+
31
+
32
+ def json_report(provider: str, *, live: bool = False) -> dict[str, Any]:
33
+ command = [executable(provider)]
34
+ command += ["status", "--json"] if provider == "codex" else [
35
+ "list", "--json", "--refresh" if live else "--cached"
36
+ ]
37
+ try:
38
+ result = subprocess.run(command, capture_output=True, text=True, timeout=180)
39
+ except subprocess.TimeoutExpired as exc:
40
+ raise BackendError(f"{provider} status timed out after 180 seconds") from exc
41
+ except OSError as exc:
42
+ raise BackendError(f"Could not start {COMMANDS[provider]}: {exc.strerror}") from exc
43
+ if result.returncode:
44
+ # Do not forward arbitrary backend diagnostics to a JSON report: some
45
+ # errors contain local paths or OAuth callback parameters.
46
+ raise BackendError(f"{COMMANDS[provider]} status failed (exit {result.returncode}); run it directly for details")
47
+ try:
48
+ data = json.loads(result.stdout)
49
+ except ValueError as exc:
50
+ raise BackendError(f"{COMMANDS[provider]} returned invalid JSON") from exc
51
+ if not isinstance(data, dict) or not isinstance(data.get("accounts"), list):
52
+ raise BackendError(f"{COMMANDS[provider]} returned an unsupported status schema")
53
+ return data
54
+
55
+
56
+ def remaining(value: Any) -> float | None:
57
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
58
+ return None
59
+ if not math.isfinite(value) or not 0 <= value <= 100:
60
+ return None
61
+ return round(100 - value, 2)
62
+
63
+
64
+ def normalize(provider: str, data: dict[str, Any]) -> list[dict[str, Any]]:
65
+ """Copy only public, known fields; never return backend credential blobs.
66
+
67
+ The Codex JSON API uses zero-based indexes, while switch uses one-based
68
+ numbers. Antigravity's public number is already one-based.
69
+ """
70
+ output = []
71
+ for account in data["accounts"]:
72
+ if not isinstance(account, dict):
73
+ raise BackendError(f"{provider} returned an invalid account record")
74
+ quotas = []
75
+ if provider == "codex":
76
+ index = account.get("index")
77
+ if isinstance(index, bool) or not isinstance(index, int) or index < 0:
78
+ raise BackendError("Codex account has an invalid index")
79
+ number = index + 1
80
+ label = account.get("label")
81
+ label = label if isinstance(label, str) else f"Account {number}"
82
+ match = re.search(r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}", label)
83
+ email = match.group(0) if match else None
84
+ active = account.get("current") is True
85
+ subscription = account.get("subscription") or {}
86
+ subscription = subscription if isinstance(subscription, dict) else {}
87
+ plan = subscription.get("planLabel")
88
+ auto_check = account.get("autoPrime") is True
89
+ quota = account.get("quota") or {}
90
+ quota = quota if isinstance(quota, dict) else {}
91
+ for key in ("primary", "secondary"):
92
+ window = quota.get(key)
93
+ if isinstance(window, dict):
94
+ quotas.append({
95
+ "name": key,
96
+ "window_minutes": window.get("windowMinutes"),
97
+ "remaining_percent": remaining(window.get("usedPercent")),
98
+ "reset_at_ms": window.get("resetAtMs"),
99
+ })
100
+ else:
101
+ number = account.get("number")
102
+ if isinstance(number, bool) or not isinstance(number, int) or number < 1:
103
+ raise BackendError("Antigravity account has an invalid number")
104
+ email = account.get("email")
105
+ active = account.get("active") is True
106
+ plan, auto_check = None, None
107
+ usage = account.get("usage") or {}
108
+ usage = usage if isinstance(usage, dict) else {}
109
+ for name, window in usage.items():
110
+ if not isinstance(window, dict):
111
+ continue
112
+ quotas.append({
113
+ "name": name,
114
+ "remaining_percent": remaining(window.get("usedPct")),
115
+ "reset_at": window.get("resetsAt"),
116
+ })
117
+ output.append({
118
+ "provider": provider, "number": number, "email": email,
119
+ "active": active, "plan": plan, "automatic_checks": auto_check,
120
+ "quota": quotas,
121
+ })
122
+ return output
123
+
124
+
125
+ def action(provider: str, operation: str, target: str | None = None) -> list[str]:
126
+ if operation == "login":
127
+ return [executable("codex"), "login", "--device-auth"] if provider == "codex" else require_agy()
128
+ if operation == "check":
129
+ return [executable(provider)] + (["check", "accounts"] if provider == "codex" else ["list", "--refresh"])
130
+ if operation == "add":
131
+ if provider != "agy":
132
+ raise BackendError("Use 'clighost login codex' to add a Codex account")
133
+ return [executable(provider), "add"]
134
+ if operation == "switch":
135
+ if not target or not target.isdecimal() or int(target) < 1:
136
+ raise BackendError("Account number must be a positive integer from 'clighost status'")
137
+ return [executable(provider), "switch", str(int(target))]
138
+ if operation == "run":
139
+ if provider == "codex":
140
+ path = shutil.which("mcodex")
141
+ if not path:
142
+ raise BackendError(f"Missing mcodex. Install: {INSTALL['codex']}")
143
+ return [path]
144
+ return [executable(provider), "run", "--"]
145
+ raise BackendError(f"Unsupported operation: {operation}")
146
+
147
+
148
+ def require_agy() -> list[str]:
149
+ path = shutil.which("agy")
150
+ if not path:
151
+ raise BackendError("Missing agy. Install the official Antigravity CLI first")
152
+ return [path]
@@ -0,0 +1,129 @@
1
+ """Command-line entry point. OAuth stays in the upstream managers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+
12
+ from . import __version__
13
+ from .backends import BackendError, action, json_report, normalize
14
+
15
+ PROVIDERS = ("codex", "agy")
16
+
17
+
18
+ def parser() -> argparse.ArgumentParser:
19
+ root = argparse.ArgumentParser(prog="clighost", description="CliGhost by VELTEIN — manage Codex and Antigravity accounts")
20
+ root.add_argument("--version", action="version", version=f"CliGhost {__version__} by VELTEIN")
21
+ commands = root.add_subparsers(dest="command", required=True)
22
+ status = commands.add_parser("status", help="Show accounts and cached quota; never shows tokens")
23
+ status.add_argument("provider", choices=(*PROVIDERS, "all"), nargs="?", default="all")
24
+ status.add_argument("--json", action="store_true", help="Emit normalized schema_version=1 JSON")
25
+ status.add_argument("--live", action="store_true", help="Refresh quota; Codex performs live inference checks")
26
+ doctor = commands.add_parser("doctor", help="Check the four required commands on PATH")
27
+ doctor.add_argument("--json", action="store_true")
28
+ for name, help_text in (
29
+ ("login", "Add an account through the provider's OAuth login"),
30
+ ("check", "Check live authentication and quota"),
31
+ ("add", "Save the currently signed-in Antigravity account"),
32
+ ("switch", "Switch or pin an account by its displayed number"),
33
+ ("run", "Launch a provider with its existing failover wrapper"),
34
+ ):
35
+ command = commands.add_parser(name, help=help_text)
36
+ command.add_argument("provider", choices=PROVIDERS)
37
+ if name == "switch":
38
+ command.add_argument("number", help="One-based number shown in status")
39
+ if name == "run":
40
+ command.add_argument("args", nargs=argparse.REMAINDER, help="Provider arguments after --")
41
+ return root
42
+
43
+
44
+ def run_command(command: list[str]) -> int:
45
+ try:
46
+ return subprocess.call(command)
47
+ except OSError as exc:
48
+ raise BackendError(f"Could not start command: {exc.strerror}") from exc
49
+
50
+
51
+ def status(args: argparse.Namespace) -> int:
52
+ reports, errors = [], {}
53
+ providers = PROVIDERS if args.provider == "all" else (args.provider,)
54
+ for provider in providers:
55
+ try:
56
+ if args.live and provider == "codex":
57
+ # Keep stdout valid JSON while allowing progress on stderr.
58
+ result = subprocess.run(action(provider, "check"), stdout=sys.stderr, stderr=sys.stderr)
59
+ if result.returncode:
60
+ raise BackendError(f"Codex live check failed (exit {result.returncode})")
61
+ reports.extend(normalize(provider, json_report(provider, live=args.live)))
62
+ except BackendError as exc:
63
+ errors[provider] = str(exc)
64
+ if args.json:
65
+ print(json.dumps({"schema_version": 1, "accounts": reports, "errors": errors}, indent=2))
66
+ else:
67
+ for provider in providers:
68
+ print(f"{provider.upper()} accounts")
69
+ accounts = [a for a in reports if a["provider"] == provider]
70
+ if provider in errors:
71
+ print(f" Error: {errors[provider]}")
72
+ elif not accounts:
73
+ print(f" No accounts. Run: clighost login {provider}")
74
+ for account in accounts:
75
+ current = " [active]" if account["active"] else ""
76
+ print(f" {account['number']}. {account['email'] or 'Email unavailable'}{current}")
77
+ for quota in account["quota"]:
78
+ name = quota["name"]
79
+ minutes = quota.get("window_minutes")
80
+ if minutes == 300:
81
+ name = "5h"
82
+ elif minutes == 10080:
83
+ name = "weekly"
84
+ percent = quota["remaining_percent"]
85
+ print(f" {name}: " + ("unknown" if percent is None else f"{percent:g}% left"))
86
+ if not account["quota"]:
87
+ print(" Quota unknown; run clighost check " + provider)
88
+ print("\nQuota is cached unless --live is used; unknown quota does not mean zero.")
89
+ return 1 if errors else 0
90
+
91
+
92
+ def main(argv: list[str] | None = None) -> int:
93
+ args = parser().parse_args(argv)
94
+ try:
95
+ if args.command == "doctor":
96
+ commands = {name: shutil.which(name) for name in ("codex-ma", "mcodex", "aswap", "agy")}
97
+ if args.json:
98
+ print(json.dumps({"commands": commands}, indent=2))
99
+ else:
100
+ for name, path in commands.items():
101
+ print(f"{name}: {path or 'MISSING'}")
102
+ return 0 if all(commands.values()) else 1
103
+ if args.command == "status":
104
+ return status(args)
105
+ command = action(args.provider, args.command, getattr(args, "number", None))
106
+ if args.command == "run":
107
+ forwarded = args.args[1:] if args.args[:1] == ["--"] else args.args
108
+ # Replace this process, preserving stdin, terminal, signal delivery,
109
+ # cwd and all backend flags rather than capturing a live session.
110
+ os.execv(command[0], command + forwarded)
111
+ if args.command == "login" and args.provider == "agy":
112
+ # Validate both tools before asking the user to sign in.
113
+ add = action("agy", "add")
114
+ print("In agy, sign in to the account you want to save, then exit. clighost will save that login.", file=sys.stderr)
115
+ code = run_command(command)
116
+ return code if code else run_command(add)
117
+ return run_command(command)
118
+ except BackendError as exc:
119
+ print(f"clighost: {exc}", file=sys.stderr)
120
+ return 1
121
+ except OSError as exc:
122
+ print(f"clighost: could not launch provider: {exc.strerror}", file=sys.stderr)
123
+ return 1
124
+ except KeyboardInterrupt:
125
+ return 130
126
+
127
+
128
+ if __name__ == "__main__":
129
+ sys.exit(main())