diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..768dbcc --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +CLAUDE.MD +AGENTS.MD +.projectmem/* +__pycache__/* +*.pyc +.ruff_cache/* \ No newline at end of file diff --git a/README.md b/README.md index 9497bc9..0386dea 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ This is not an official OpenAI or Codex tool. It does not redeem credits, buy cr ## Requirements - Python 3.10 or newer. -- macOS or Linux. -- Local Codex state under `~/.codex` for `local-usage`. -- A Codex login at `~/.codex/auth.json` and network access for `resets`, `online-usage`, `all` and menu quick summaries. +- macOS, Linux or Windows. +- Local Codex state under your Codex home directory for `local-usage`. +- A Codex login at `auth.json` inside your Codex home directory and network access for `resets`, `online-usage`, `all` and menu quick summaries. -No third-party Python packages are required. Windows is not supported because the script expects Unix-style paths, an executable shebang workflow and a Codex home at `~/.codex`. +No third-party Python packages are required. By default, Codex Usage reads Codex data from `Path.home() / ".codex"`. Set `CODEX_HOME` to use a different Codex home directory. The source layout is deliberately small: @@ -44,14 +44,39 @@ If you prefer not to mark the file executable, run it through Python: python3 codex_usage.py ``` +On Windows, open PowerShell in the folder that contains `codex_usage.py`, then run the script with the Python launcher: + +```powershell +py -3 .\codex_usage.py +``` + +Run a local-only report: + +```powershell +py -3 .\codex_usage.py local-usage +``` + You can check the script syntax before running it: ```sh python3 -m py_compile ./codex_usage.py ``` +PowerShell equivalent: + +```powershell +py -3 -m py_compile .\codex_usage.py +``` + The syntax check only verifies that Python can parse the script. It does not contact Codex and does not read your account data. +If your Codex data is not in the default user-profile `.codex` directory, set `CODEX_HOME` before running the script: + +```powershell +$env:CODEX_HOME = "C:\Users\Mike\.codex" +py -3 .\codex_usage.py local-usage +``` + ## Use The Reports In an interactive terminal, running the script without arguments opens the menu. In non-interactive use, the same entry point prints the `all` report. @@ -164,7 +189,7 @@ Shared display switches: | `--days N` | `all`, `menu`, `local-usage`, `export` | Number of recent daily local-usage rows to show/include. | `30` | | `--warn-days N` | `all`, `menu`, `resets`, `export` | Warn when reset credits expire within this many days. Use `0` to disable soon-expiry warnings. | `7` | -The menu and commands use the same display settings. `top` controls ranked-table length, such as top sessions or model usage. `days` controls how many recent calendar days appear in daily local-usage tables. `warn_days` controls how soon reset-credit expiry should produce a warning; use `0` to disable soon-expiry warnings. These settings affect display and export size only. They do not change Codex, your account or `~/.codex`. +The menu and commands use the same display settings. `top` controls ranked-table length, such as top sessions or model usage. `days` controls how many recent calendar days appear in daily local-usage tables. `warn_days` controls how soon reset-credit expiry should produce a warning; use `0` to disable soon-expiry warnings. These settings affect display and export size only. They do not change Codex, your account, your Codex home directory or any server setting. ## Exports @@ -208,14 +233,16 @@ The script never removes exported reports. If you export inside a Git checkout, Codex Usage reuses your existing Codex login file: ```text -~/.codex/auth.json +/auth.json ``` +The Codex home directory is `Path.home() / ".codex"` unless `CODEX_HOME` is set. + The script reads the access token and account ID from that file when it calls Codex/ChatGPT backend endpoints. It does not print them, and you do not need an OpenAI API key. Online responses are redacted before display or export. Token-like and identity-like fields are filtered by sensitive field name, including access tokens, refresh tokens, ID tokens, authorisation headers, cookies, session values, account IDs, email fields, phone fields, passwords and secrets. Email addresses inside string values are also redacted. -Local usage mode reads metadata and counters from `~/.codex`. It avoids prompt text, assistant text, command text, diffs, transcripts and secret contents. +Local usage mode reads metadata and counters from your Codex home directory. It avoids prompt text, assistant text, command text, diffs, transcripts and secret contents. ## Network Behaviour @@ -249,7 +276,9 @@ If `./codex_usage.py` says permission is denied, make it executable: chmod +x codex_usage.py ``` -If the script says `~/.codex/auth.json` is missing or malformed, sign in to Codex first, then run the script again. Codex Usage reuses that existing login; it does not ask for, store or need an OpenAI API key. +If the script says `/auth.json` is missing or malformed, sign in to Codex first, then run the script again. Codex Usage reuses that existing login; it does not ask for, store or need an OpenAI API key. + +By default, `` is `Path.home() / ".codex"`, so existing users with the default location do not need to change anything. On Windows or custom layouts, set `CODEX_HOME` when Codex data is not under the default user-profile `.codex` directory. If online sections fail but `local-usage` works, the likely causes are network access, an expired Codex login, or undocumented backend endpoints changing. You can still run the local-only report without network access: @@ -285,7 +314,7 @@ pyflakes ./codex_usage.py python3 codex_usage.py --help ``` -Do not include access tokens, `~/.codex/auth.json`, exported reports, raw backend responses, local transcripts, private prompts, private paths or account data in issues, commits, fixtures or screenshots. +Do not include access tokens, `/auth.json`, exported reports, raw backend responses, local transcripts, private prompts, private paths or account data in issues, commits, fixtures or screenshots. Please report security or privacy issues privately instead of publishing exploit details. diff --git a/codex_usage.py b/codex_usage.py index b4f4437..250200a 100755 --- a/codex_usage.py +++ b/codex_usage.py @@ -38,8 +38,16 @@ from pathlib import Path from typing import Any, Callable -AUTH_PATH = Path("~/.codex/auth.json").expanduser() -CODEX_HOME = Path("~/.codex").expanduser() + +def resolve_codex_home() -> Path: + codex_home = os.environ.get("CODEX_HOME") + if codex_home: + return Path(codex_home).expanduser() + return Path.home() / ".codex" + + +CODEX_HOME = resolve_codex_home() +AUTH_PATH = CODEX_HOME / "auth.json" SCRIPT_DIR = Path(__file__).resolve().parent EXPORT_DIR = SCRIPT_DIR API_BASE = "https://chatgpt.com/backend-api" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_codex_paths.py b/tests/test_codex_paths.py new file mode 100644 index 0000000..e42bc67 --- /dev/null +++ b/tests/test_codex_paths.py @@ -0,0 +1,55 @@ +import importlib +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +codex_usage = importlib.import_module("codex_usage") + + +class CodexPathTests(unittest.TestCase): + def reload_codex_usage( + self, *, codex_home: str | None = None, home: Path | None = None + ): + env = {"CODEX_HOME": codex_home} if codex_home is not None else {} + + with patch.dict(os.environ, env, clear=False): + if codex_home is None: + os.environ.pop("CODEX_HOME", None) + if home is None: + module = importlib.reload(codex_usage) + return module, module.resolve_codex_home() + with patch.object(Path, "home", return_value=home): + module = importlib.reload(codex_usage) + return module, module.resolve_codex_home() + + def tearDown(self) -> None: + importlib.reload(codex_usage) + + def test_codex_home_uses_codex_home_environment_variable(self) -> None: + override = Path("C:/Users/Mike/AppData/Roaming/Codex") + + module, resolved = self.reload_codex_usage(codex_home=str(override)) + + self.assertEqual(resolved, override) + self.assertEqual(module.CODEX_HOME, override) + self.assertEqual(module.AUTH_PATH, override / "auth.json") + + def test_codex_home_defaults_to_dot_codex_under_home(self) -> None: + home = Path("C:/Users/Mike") + expected = home / ".codex" + + module, resolved = self.reload_codex_usage(home=home) + + self.assertEqual(resolved, expected) + self.assertEqual(module.CODEX_HOME, expected) + self.assertEqual(module.AUTH_PATH, expected / "auth.json") + + +if __name__ == "__main__": + unittest.main()