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 +21 -0
- package/README.md +174 -2
- package/bin/clighost.cjs +67 -0
- package/package.json +51 -4
- package/src/agent_accounts/__init__.py +3 -0
- package/src/agent_accounts/backends.py +152 -0
- package/src/agent_accounts/cli.py +129 -0
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
|
-
#
|
|
1
|
+
# CliGhost
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**One terminal. Multiple identities.**
|
|
4
|
+
|
|
5
|
+
Created by **Issam El Bani** at **[VELTEIN](https://veltein.com)**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/clighost)
|
|
8
|
+
[](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
|
+
[](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.
|
package/bin/clighost.cjs
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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,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())
|