From 684d822b235f629ecbf2c1fc718ee6e061dc8663 Mon Sep 17 00:00:00 2001 From: RS Nikhil Krishna Date: Thu, 8 Oct 2026 11:28:15 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Add=20coding-setup=20kit,=20Nod?= =?UTF-8?q?e=2024=20base,=20loud=20quota=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track coding-setup/: CLIProxy + quota console + T3 + Tailscale rebuild kit (install.sh, check.sh, config templates, console with tests) - install.sh: unpinned CLIProxyAPI (newest release, GitHub asset sha256), new `agents` phase for claude, cursor-agent and opencode2, T3 Nightly selection that ignores preview builds, management key kept off argv, tailscale reminder to remove older devices - Console: capacity-weighted pooled 7-day usage across Claude accounts, account type shown for every account and client, and any Anthropic API failure or unknown plan tier now renders an error card and banner - 1-BasicSetUp.sh installs Node.js 24 by default - Ubuntu 26.04 is Resolute Raccoon in README and CI Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 4 +- 1-BasicSetUp.sh | 11 +- AGENTS.md | 10 + README.md | 6 +- coding-setup/AGENTS.md | 43 ++ coding-setup/README.md | 142 ++++ coding-setup/check.sh | 129 ++++ coding-setup/cliproxy-console/.gitignore | 2 + coding-setup/cliproxy-console/README.md | 69 ++ .../cliproxy-console/lib/consumption.mjs | 119 ++++ .../cliproxy-console/lib/consumption.test.mjs | 63 ++ .../cliproxy-console/lib/normalize.mjs | 569 ++++++++++++++++ .../cliproxy-console/lib/normalize.test.mjs | 224 +++++++ coding-setup/cliproxy-console/package.json | 10 + coding-setup/cliproxy-console/public/app.js | 628 ++++++++++++++++++ .../cliproxy-console/public/index.html | 30 + .../cliproxy-console/public/styles.css | 238 +++++++ coding-setup/cliproxy-console/server.mjs | 619 +++++++++++++++++ .../config/claude-cliproxy.environment.conf | 4 + .../claude-cliproxy.settings.example.json | 8 + coding-setup/config/cli-proxy-api.config.yaml | 36 + coding-setup/config/cli-proxy-api.service | 11 + coding-setup/config/cliproxy-console.service | 16 + .../config/t3-desktop-settings.snippet.json | 5 + coding-setup/config/t3-settings.snippet.json | 21 + coding-setup/config/t3code-autostart.desktop | 8 + coding-setup/install.sh | 399 +++++++++++ 27 files changed, 3418 insertions(+), 6 deletions(-) create mode 100644 AGENTS.md create mode 100644 coding-setup/AGENTS.md create mode 100644 coding-setup/README.md create mode 100755 coding-setup/check.sh create mode 100644 coding-setup/cliproxy-console/.gitignore create mode 100644 coding-setup/cliproxy-console/README.md create mode 100644 coding-setup/cliproxy-console/lib/consumption.mjs create mode 100644 coding-setup/cliproxy-console/lib/consumption.test.mjs create mode 100644 coding-setup/cliproxy-console/lib/normalize.mjs create mode 100644 coding-setup/cliproxy-console/lib/normalize.test.mjs create mode 100644 coding-setup/cliproxy-console/package.json create mode 100644 coding-setup/cliproxy-console/public/app.js create mode 100644 coding-setup/cliproxy-console/public/index.html create mode 100644 coding-setup/cliproxy-console/public/styles.css create mode 100644 coding-setup/cliproxy-console/server.mjs create mode 100644 coding-setup/config/claude-cliproxy.environment.conf create mode 100644 coding-setup/config/claude-cliproxy.settings.example.json create mode 100644 coding-setup/config/cli-proxy-api.config.yaml create mode 100644 coding-setup/config/cli-proxy-api.service create mode 100644 coding-setup/config/cliproxy-console.service create mode 100644 coding-setup/config/t3-desktop-settings.snippet.json create mode 100644 coding-setup/config/t3-settings.snippet.json create mode 100644 coding-setup/config/t3code-autostart.desktop create mode 100755 coding-setup/install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b81e99e..957831a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,8 @@ jobs: hash -r ./2-GenSoftware.sh - oracular-basic: - name: Ubuntu Oracular (26.04) - Basic Setup + GenSoftware + resolute-basic: + name: Ubuntu Resolute Raccoon (26.04) - Basic Setup + GenSoftware runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/1-BasicSetUp.sh b/1-BasicSetUp.sh index 5f163b0..1d99ce4 100755 --- a/1-BasicSetUp.sh +++ b/1-BasicSetUp.sh @@ -205,12 +205,19 @@ else sudo systemctl restart docker fi +## Install Node.js 24 (LazyVim plugins, the coding-setup console and the agent CLIs need it) +node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)" +if [ "$node_major" -ge 24 ]; then + echo "Node.js $(node -v) already installed, skipping installation" +else + curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - + sudo apt install -y nodejs +fi + ## Install Neovim with all essential lazyvim plugins if [ -x "$(command -v nvim)" ] && [ -d ~/.config/nvim ]; then echo "Neovim and LazyVim already installed, skipping installation" else - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - - sudo apt install -y nodejs sudo apt install -y build-essential "lua5.1" luarocks ripgrep fd-find fzf mkdir -p ~/.local/bin diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..98c4678 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Agents + +This repository is two layers. + +1. `1-BasicSetUp.sh` and `2-GenSoftware.sh` rebuild a desktop Ubuntu install. Follow `README.md`. Do not run those scripts as part of an unrelated code change. +2. `coding-setup/` is the agent workstation: CLIProxy on `127.0.0.1:8317`, the quota console, T3 Code pointed at an isolated Claude home, and Tailscale Serve so other devices of the same user start sessions on this machine. + +When the task is to rebuild that workstation, replicate it on a new laptop, or change how Claude, T3, or the proxy are wired, read [coding-setup/AGENTS.md](coding-setup/AGENTS.md) and follow it. That directory is self-contained and can be split into its own repository later. + +Do not commit OAuth files, API keys, management keys, or a filled-in `config.yaml`. The copies under `coding-setup/config/` are templates. diff --git a/README.md b/README.md index 197b6a8..873bb7a 100755 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ These are the scripts that I use to set my Ubuntu up as quick as possible. Feel ## Build Status: -Every script is stable and validates with [CI](https://github.com/features/actions) to make sure everything works as expected. The scripts are tested on Ubuntu 24.04 (Noble) and 26.04 (Oracular) LTS releases. +Every script is stable and validates with [CI](https://github.com/features/actions) to make sure everything works as expected. The scripts are tested on Ubuntu 24.04 (Noble) and 26.04 (Resolute Raccoon) LTS releases. You can see the build results in the [Actions tab](https://github.com/rsnk96/Ubuntu-Setup-Scripts/actions) of this repository. @@ -13,6 +13,8 @@ You can see the build results in the [Actions tab](https://github.com/rsnk96/Ubu First download/clone this repository Then execute them in the terminal in the sequence of filenames using `./1-BasicSetUp.sh` and `./2-GenSoftware.sh`. + +The agent workstation (CLIProxy, the quota console, T3 Code, and Tailscale) is a separate layer. After the two scripts, `cd coding-setup && ./install.sh` builds it and `./check.sh` verifies it. Its source, sanitized configs, and notes live in [`coding-setup/`](coding-setup/README.md). An agent rebuilding that stack should start at [`coding-setup/AGENTS.md`](coding-setup/AGENTS.md). * `1-BasicSetUp.sh` - Sets up terminal configuration (Zsh + Zim), download accelerator (aria2), Anaconda Python, shell aliases, Docker, Nvidia drivers (if detected), Neovim with LazyVim, and other essential tools. **Note:** After running this script, you should reboot your PC if you have an Nvidia GPU so that the display driver loads properly. * `2-GenSoftware.sh` - Installs general purpose software including VS Code, Cursor IDE, browsers (Brave, Chrome), GitHub and GitLab CLIs, the AWS Session Manager plugin, xhisper, dltop, herdr, Snap VLC and Surfshark, Stremio, and other utilities. @@ -67,7 +69,7 @@ Log out and back in once both scripts finish. Reboot instead if an Nvidia driver * **Adding a new OS user:** If setting up multiple users on the OS, try to use the script `./add_new_user.sh` in this repo. It will set the correct settings for them to also benefit from zsh, conda, etc. * Make sure that your system time and date is correct and synchronized before running the scripts, otherwise this will cause failure while trying to download the packages. * These scripts are written and tested on the following configurations: - * Ubuntu 24.04 (Noble) and 26.04 (Oracular) LTS releases + * Ubuntu 24.04 (Noble) and 26.04 (Resolute Raccoon) LTS releases * 64-bit Intel/AMD Processors * NVIDIA GPUs (drivers will be automatically installed if detected) diff --git a/coding-setup/AGENTS.md b/coding-setup/AGENTS.md new file mode 100644 index 0000000..5cc78d3 --- /dev/null +++ b/coding-setup/AGENTS.md @@ -0,0 +1,43 @@ +# Coding setup agents + +When the user wants this workstation rebuilt, moved to a new machine, or wired the same way again, run `./install.sh` and follow [README.md](README.md). The script is the procedure, the README explains it, and this file is the contract. If they disagree, trust the script for commands and this file for what you must not do. + +This directory is the source of truth and can stand alone as its own repository. The parent `AGENTS.md` only points here. + +## Do this + +- On a blank Ubuntu machine, run the parent `1-BasicSetUp.sh` and `2-GenSoftware.sh` first, when those scripts are present. +- Run `./install.sh`, then `./install.sh login claude` once per subscription in a browser on that machine (`codex` only if the user asks), then `./check.sh`. Report the real output of `check.sh`; a WARN is not a pass. +- Prefer re-running one phase to editing a deployed file by hand. Phases are idempotent and merge into existing settings. +- Keep the proxy on `127.0.0.1:8317` with `management.allow-remote: false`, and T3 on loopback (`serverExposureMode: "local-only"`). Tailscale Serve is published by T3 itself. +- Leave `CLAUDE_CONFIG_DIR` pointed at `~/.claude-cliproxy` for the login session. `install.sh` writes `~/.config/environment.d/claude-cliproxy.conf`. It is read at login, and T3's history lookup uses it instead of `providers.claudeAgent.homePath`. +- CLIProxyAPI is not pinned: `install.sh proxy` installs the newest release and checks the sha256 GitHub publishes for the asset. After an upgrade, run `npm test` in `cliproxy-console/` and read the board for an Anthropic or management-API error banner. +- Run `npm test` in `cliproxy-console/` after any console change. The console runs from the repo through `~/.local/share/cliproxy-console`, so `systemctl --user restart cliproxy-console.service` picks the edit up. +- Change the README, `install.sh`, `check.sh` and the units together. A behaviour that exists in only one of them is a bug. + +## Do not do this + +- Do not copy `~/.cli-proxy-api/*.json`, `~/.claude-cliproxy/.credentials.json`, `keys.env`, `cliproxy-console/data/settings.json`, or a filled-in `config.yaml` into the repository. +- Do not print management keys, client API keys, OAuth tokens, or Tailscale login URLs in chat, commit logs, or a pasted process listing. Do not put a key on a `curl` command line. +- Do not run `tailscale serve` by hand against T3. Do not publish port 8317 or 8787 with Serve or Funnel. Do not enable Funnel, an exit node, or subnet routes. +- Do not set `server.host` to an empty string or `0.0.0.0`, and do not set T3's `serverExposureMode` to `network-accessible`. +- Do not unset `CLAUDE_CONFIG_DIR` or point it at `~/.claude`. Session fork will not see transcripts written under the isolated home. +- Do not replace `~/.t3/userdata/settings.json` or `desktop-settings.json` wholesale. Edit `desktop-settings.json` only while T3 is closed; the app rewrites it on exit. +- Do not start the console by hand (`node server.mjs`) and leave it running. It must be the systemd unit, or it dies with the shell that started it. +- Do not point the console unit at a Node that cannot import `node:sqlite`. The unit's `ExecStartPre` will refuse to start; fix the Node, not the check. +- Do not `enable-linger` or enable autologin unless the user wants services to outlive a desktop login. Say that the T3 desktop app still stops at logout. +- A Claude login callback from a dead process cannot be redeemed. Start a new `./install.sh login claude`. + +## Files an agent may edit + +| Change | Where | +| --- | --- | +| Quota board behaviour | `cliproxy-console/` | +| Default proxy routing | `config/cli-proxy-api.config.yaml` | +| Units | `config/*.service` | +| Claude overlay | `config/claude-cliproxy.settings.example.json` | +| Login-session `CLAUDE_CONFIG_DIR` | `config/claude-cliproxy.environment.conf` | +| T3 keys this stack owns | `config/t3-settings.snippet.json`, `config/t3-desktop-settings.snippet.json` | +| Login autostart | `config/t3code-autostart.desktop` | +| Provisioning and verification | `install.sh`, `check.sh` | +| Rebuild steps | `README.md` and this file, together | diff --git a/coding-setup/README.md b/coding-setup/README.md new file mode 100644 index 0000000..aa50352 --- /dev/null +++ b/coding-setup/README.md @@ -0,0 +1,142 @@ +# Coding setup + +Rebuild kit for the local agent workstation. It is self-contained and can be split into its own repository. + +What you get: + +- CLIProxyAPI on `127.0.0.1:8317`: one OAuth login per Claude subscription, sticky sessions, failover when an account returns 429. +- A quota console on `127.0.0.1:8787` (`cliproxy-console/`). +- An isolated Claude home, `~/.claude-cliproxy`, that sends Claude Code through the proxy. The login session exports `CLAUDE_CONFIG_DIR` to that home, which is what T3's history lookup reads. `~/.claude` is not rewritten. +- T3 Code (Nightly) using that home, started at login, so threads run on this machine. +- Tailscale Serve, published by T3 itself, so your other devices open T3 over HTTPS. Nothing else is published. + +Recorded from the working laptop on 2026-10-04: CLIProxyAPI 8.0.13 (new installs take the newest release), T3 Code `0.0.46~nightly.20261004.2657`, Tailscale 1.102.4, Node with `node:sqlite` (22.13 or newer). The console speaks the v8 management API, so read the release notes before moving past 8.x. + +## New machine + +```bash +# 1. Base system (repo root) +./1-BasicSetUp.sh && ./2-GenSoftware.sh # then log out and back in + +# 2. Everything in this directory +cd coding-setup +./install.sh # asks for sudo where needed + +# 3. One browser login per Claude subscription +./install.sh login claude # repeat per account +./install.sh login codex # optional + +# 4. Verify +./check.sh +``` + +`install.sh` is idempotent. Re-run it, or a single phase (`./install.sh console t3`), whenever something drifts. It never regenerates an existing key or overwrites other keys in your settings files. + +| Phase | What it does | +| --- | --- | +| `node` | Finds a Node that imports `node:sqlite` (`$CLIPROXY_NODE`, `/usr/bin/node`, then `node` on `PATH`). If none works it installs NodeSource Node 24. | +| `agents` | Installs the Claude Code CLI (`claude`), Cursor Agent (`cursor-agent`) and OpenCode 2 (`opencode2`, from `@opencode/cli` through Bun) when they are missing. | +| `proxy` | Installs the newest CLIProxyAPI release (re-running upgrades it) with the sha256 GitHub publishes for the asset checked, generates the two keys into `~/.config/cli-proxy-api/keys.env`, renders `config.yaml`, enables `cli-proxy-api.service`. | +| `claude` | Writes the proxy `env` into `~/.claude-cliproxy/settings.json` (other keys kept), links `CLAUDE.md`, `skills`, `plugins`, `hooks` from `~/.claude` when they exist, and installs `~/.config/environment.d/claude-cliproxy.conf`. | +| `console` | Runs the console tests, symlinks the repo copy to `~/.local/share/cliproxy-console`, seeds `data/settings.json`, enables `cliproxy-console.service` pinned to the Node it found. | +| `t3` | Installs the newest T3 Nightly `.deb` from the [GitHub releases page](https://github.com/pingdotgg/t3code/releases) (sha256 checked; set `T3_DEB` to use one you downloaded), merges `config/t3-settings.snippet.json` into `settings.json` and `config/t3-desktop-settings.snippet.json` into `desktop-settings.json`, and installs the same `CLAUDE_CONFIG_DIR` drop-in as the `claude` phase. | +| `autostart` | Installs `~/.config/autostart/t3code.desktop`. | +| `tailscale` | Installs Tailscale and joins your tailnet with `--operator="$USER" --accept-routes=false`, then reminds you to delete the older devices in the admin console. | +| `power` | Opt-in. Never suspend on AC, ignore the lid switch. For a box that stays closed on a desk. | +| `check` | Runs `check.sh`. | + +T3 Code has to come from the releases page. Nightly builds are prereleases there, so the "Latest" badge points at an older stable build; take the newest `T3-Code--nightly..-.deb`. Do not use the t3.codes install script. To install by hand, download that `.deb` and run `sudo apt install ./T3-Code-*.deb`, then `./install.sh t3` for the settings. + +Two phases need a human: `tailscale` prints a device-login URL (a secret, do not paste it anywhere), and the first Serve start can refuse until HTTPS is enabled for the tailnet. It prints an admin URL; enable it, then restart T3. + +## What survives a restart + +| Piece | Mechanism | After a reboot | +| --- | --- | --- | +| `tailscaled` | system service, enabled by the Tailscale package | up at boot | +| CLIProxyAPI, console | user units, `WantedBy=default.target` | up when your user session starts | +| T3 Code | `~/.config/autostart/t3code.desktop`, 10 s delay | up at login | +| `CLAUDE_CONFIG_DIR` | `~/.config/environment.d/claude-cliproxy.conf` | set on the next login | +| Tailscale Serve | T3 publishes it on every start (`tailscaleServeEnabled`) | up when T3 is | +| Claude logins | `~/.cli-proxy-api/*.json`, refreshed by the proxy | unchanged | + +Nothing here starts before you log in to the desktop. For a primary box that must come back unattended, pick one: + +- GDM autologin (`AutomaticLoginEnable=true` in `/etc/gdm3/custom.conf`). Only with full-disk encryption and a physically safe machine. +- `sudo loginctl enable-linger "$USER"` keeps the proxy and console up without a login, but T3 is still a desktop app and does not start. For a box with no login at all, run `t3 serve --tailscale-serve` from your own user unit instead of the desktop app; that unit is not shipped here. + +`./check.sh` prints a WARN while linger is off. + +## What never goes in git + +OAuth files in `~/.cli-proxy-api/`, `keys.env`, the filled-in `config.yaml`, `cliproxy-console/data/settings.json`, `~/.claude-cliproxy/.credentials.json` (MCP tokens), and Tailscale login URLs. The templates in `config/` carry `REPLACE_WITH_*` placeholders and `install.sh` fills them on the machine. + +Copy `~/.config/cli-proxy-api/keys.env` to a password manager after the first run. The proxy rewrites the management key in `config.yaml` to a bcrypt hash, so `keys.env` is the only place the plaintext lives besides the console and T3 settings. Do not copy `~/.cli-proxy-api` between machines; log in again. + +## Layout + +| Path | Role | +| --- | --- | +| `install.sh`, `check.sh` | Provision and verify. | +| `cliproxy-console/` | Quota board. Source of truth. | +| `config/cli-proxy-api.config.yaml` | Proxy config template (v8 spelling). | +| `config/cli-proxy-api.service`, `config/cliproxy-console.service` | User units. The console unit has an `@NODE@` placeholder. | +| `config/claude-cliproxy.settings.example.json` | The only Claude settings this stack adds. | +| `config/claude-cliproxy.environment.conf` | Login-session `CLAUDE_CONFIG_DIR`. `@CLAUDE_HOME@` is filled in by `install.sh`. | +| `config/t3-settings.snippet.json` | Keys merged into T3's `settings.json`. | +| `config/t3-desktop-settings.snippet.json` | Keys merged into T3's `desktop-settings.json`: loopback bind and Tailscale Serve. | +| `config/t3code-autostart.desktop` | Login autostart. | +| `AGENTS.md` | Contract for an agent doing this rebuild. | + +The repo directory is where the console runs from, through the symlink. If you move the repo, run `./install.sh console` again. + +## How it fits together + +```text +other device -- Tailscale HTTPS :443 --> T3 on this machine, 127.0.0.1:3773 +login session CLAUDE_CONFIG_DIR --> ~/.claude-cliproxy +T3 Claude provider --> ~/.claude-cliproxy --> 127.0.0.1:8317 CLIProxy +CLIProxy --> one sticky Claude OAuth account, failover on 429 +console :8787 --> CLIProxy management API, loopback only +``` + +- `CLAUDE_CONFIG_DIR` is exported for the whole login, including a T3 that the updater restarts by executing its own binary. `providers.claudeAgent.homePath` does not feed that lookup. The drop-in is read at login, so a machine that just ran `install.sh` still needs a log out and back in. The same variable is what a terminal `claude` sees, so that command uses the isolated home too. +- T3 runs `tailscale serve` itself when `tailscaleServeEnabled` is true in `~/.t3/userdata/desktop-settings.json`. Do not also run `tailscale serve` by hand; T3 reports a conflict when another backend holds the port. +- `serverExposureMode: "local-only"` binds T3 to `127.0.0.1`. `network-accessible` binds `0.0.0.0` and exposes it on Wi-Fi and any other interface. Serve proxies to loopback, so it does not need that. +- T3 rewrites `desktop-settings.json` when it exits. Edit it only while T3 is closed; `install.sh t3` skips it, with a message, while T3 runs. +- `session-affinity: true` pins a thread and its prompt cache to one account for an hour, subagents included. `routing.retry.request-retry: 3` moves it to another account after 403, 408, 429, 500, 502, 503 or 504. +- `strategy: round-robin` spreads new sessions. The console can switch to `fill-first` and raise priority on the account whose 7-day window ends soonest. +- Codex stays on HTTP until its auth file has `websockets: true`; the console's routing action sets that. + +Choices already in the T3 snippet: worktrees for new threads, auto-settle after 14 days, device support on, resume after a server update, resume a thread that hit a limit. + +The console also reads local usage for tools already on the disk (Codex `~/.codex/state_5.sqlite`, OpenCode `~/.local/share/opencode/opencode.db`, Cursor's `state.vscdb` and `ai-code-tracking.db`). Missing files show an empty section. + +## Troubleshooting + +| Symptom | Cause and fix | +| --- | --- | +| Phone shows 502 or nothing | T3 is not running. Open it, or log in on the laptop. `./check.sh` shows which layer is down. | +| Console unit restarts in a loop | Its Node cannot import `node:sqlite`. `journalctl --user -u cliproxy-console`. Re-run `./install.sh node console`, or set `CLIPROXY_NODE`. After deleting or upgrading an nvm Node, re-run `./install.sh console`. | +| Console says `EADDRINUSE` | A hand-started copy still holds :8787. Stop it; the unit is the only supported way to run it. | +| `check.sh` fails the T3 loopback test | `desktop-settings.json` still says `network-accessible`. Quit T3, run `./install.sh t3`, start T3. | +| Serve refuses on first start | HTTPS is not enabled for the tailnet. Enable it from the admin URL, then restart T3. | +| A login callback fails | The PKCE verifier died with that process. Start a new `./install.sh login claude`, never reuse the URL. | +| T3: `Claude Agent SDK query failed` / session not found | The transcript is under `~/.claude-cliproxy` and this login has no `CLAUDE_CONFIG_DIR`. Run `./install.sh claude`, log out and back in, then retry the thread. | + +## Upgrading the proxy + +Run `./install.sh proxy`. It installs the newest release when the installed one differs. Read the v8 config notes before moving past 8.x, then run `npm test` in `cliproxy-console/` after any management-API change. `~/.cli-proxy-api` stays in place, so the logins survive the binary swap. + +## Uninstall + +```bash +systemctl --user disable --now cliproxy-console.service cli-proxy-api.service +rm ~/.config/systemd/user/{cli-proxy-api,cliproxy-console}.service ~/.config/autostart/t3code.desktop ~/.config/environment.d/claude-cliproxy.conf +rm ~/.local/share/cliproxy-console ~/.local/bin/cli-proxy-api +systemctl --user daemon-reload +# Optional, destroys logins and keys: ~/.cli-proxy-api ~/.config/cli-proxy-api ~/.claude-cliproxy +# T3: remove serverExposureMode, tailscaleServeEnabled and tailscaleServePort from ~/.t3/userdata/desktop-settings.json +# and the providers.claudeAgent.homePath and usageLimitSources.cliproxy-local keys from settings.json +sudo tailscale down # or: sudo apt remove tailscale +``` diff --git a/coding-setup/check.sh b/coding-setup/check.sh new file mode 100755 index 0000000..d0ae6e8 --- /dev/null +++ b/coding-setup/check.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Health check for the agent workstation. Exits non-zero if any FAIL line prints. +# WARN lines are things that work today but will not survive a reboot or logout. +# Secrets are never put on a command line or printed. +set -uo pipefail + +KEYS_FILE="$HOME/.config/cli-proxy-api/keys.env" +PROXY_CONFIG="$HOME/.config/cli-proxy-api/config.yaml" +CONSOLE_UNIT="$HOME/.config/systemd/user/cliproxy-console.service" +T3_USERDATA="$HOME/.t3/userdata" +T3_PORT=3773 +failures=0 + +pass() { printf 'PASS %s\n' "$*"; } +warn() { printf 'WARN %s\n' "$*"; } +fail() { printf 'FAIL %s\n' "$*"; failures=$((failures + 1)); } +check() { local label="$1"; shift; if "$@" >/dev/null 2>&1; then pass "$label"; else fail "$label"; fi; } + +# True if every listener on the port is bound to loopback. +loopback_only() { + local listeners + listeners="$(ss -Htln "sport = :$1" | awk '{print $4}')" + [ -n "$listeners" ] && ! grep -qvE '^(127\.0\.0\.1|\[::1\]):' <<<"$listeners" +} + +http_code() { curl -s -m 5 -o /dev/null -w '%{http_code}' "$1" 2>/dev/null; } + +json_get() { + python3 - "$1" "$2" <<'PY' 2>/dev/null +import json, sys +value = json.load(open(sys.argv[1])) +for part in sys.argv[2].split("."): + value = value[part] +print(str(value).lower()) +PY +} + +echo "-- services (survive reboot)" +for unit in cli-proxy-api.service cliproxy-console.service; do + check "$unit enabled" systemctl --user is-enabled "$unit" + check "$unit active" systemctl --user is-active "$unit" +done +check "tailscaled enabled" systemctl is-enabled tailscaled +if [ -f "$HOME/.config/autostart/t3code.desktop" ]; then pass "T3 autostart entry present"; else fail "T3 autostart entry missing (T3 will not start after a reboot)"; fi +if [ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" = "yes" ]; then + pass "linger on (user services run without a login)" +else + warn "linger off: nothing starts after a reboot until you log in to the desktop" +fi + +echo "-- console runtime" +console_node="$(sed -n 's/^ExecStart=\(.*\) server.mjs$/\1/p' "$CONSOLE_UNIT" 2>/dev/null)" +if [ -n "$console_node" ] && [ -x "$console_node" ]; then + check "console Node $console_node can import node:sqlite" "$console_node" -e "import('node:sqlite')" +else + fail "console unit has no usable Node (${console_node:-no unit installed})" +fi +if [ -L "$HOME/.local/share/cliproxy-console" ] && [ -f "$HOME/.local/share/cliproxy-console/server.mjs" ]; then + pass "console symlink resolves" +else + fail "console symlink missing or dangling" +fi + +echo "-- exposure" +check "proxy :8317 is loopback only" loopback_only 8317 +check "console :8787 is loopback only" loopback_only 8787 +if loopback_only "$T3_PORT"; then + pass "T3 :$T3_PORT is loopback only" +else + fail "T3 :$T3_PORT is reachable from the network; set serverExposureMode to local-only in $T3_USERDATA/desktop-settings.json, quit T3 and start it again" +fi + +echo "-- endpoints" +[ "$(http_code http://127.0.0.1:8787/)" = 200 ] && pass "console answers" || fail "console does not answer on :8787" +[ "$(http_code http://127.0.0.1:$T3_PORT/)" = 200 ] && pass "T3 answers" || fail "T3 does not answer on :$T3_PORT (is the app running?)" +if [ -f "$KEYS_FILE" ]; then + . "$KEYS_FILE" + code="$(curl -s -m 10 -o /dev/null -w '%{http_code}' --config <(printf 'header = "Authorization: Bearer %s"\n' "$CLIENT_API_KEY") http://127.0.0.1:8317/v1/models)" + [ "$code" = 200 ] && pass "proxy accepts the client key" || fail "proxy /v1/models returned $code with the client key" +else + [ "$(http_code http://127.0.0.1:8317/v1/models)" = 401 ] && pass "proxy listening (no keys.env here, so the client key was not tried)" || fail "proxy not answering on :8317" +fi + +echo "-- agent CLIs" +for tool in claude cursor-agent opencode2; do + if PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" command -v "$tool" >/dev/null; then pass "$tool installed"; else fail "$tool missing: run ./install.sh agents"; fi +done + +echo "-- accounts" +auth_count="$(find "$HOME/.cli-proxy-api" -maxdepth 1 -name 'claude-*.json' 2>/dev/null | wc -l)" +if [ "$auth_count" -gt 0 ]; then pass "$auth_count Claude login(s) on disk"; else fail "no Claude login: run ./install.sh login claude"; fi + +echo "-- config" +if [ -f "$PROXY_CONFIG" ]; then + [ "$(stat -c %a "$PROXY_CONFIG")" = 600 ] && pass "proxy config mode 600" || fail "proxy config is not mode 600" + grep -q '^request-retry:' "$PROXY_CONFIG" && warn "proxy config uses the legacy top-level request-retry; move it under routing.retry" +else + fail "proxy config missing" +fi +[ "$(json_get "$HOME/.claude-cliproxy/settings.json" env.ANTHROPIC_BASE_URL)" = "http://127.0.0.1:8317" ] && pass "Claude home points at the proxy" || fail "Claude home does not point at the proxy" +session_env="$HOME/.config/environment.d/claude-cliproxy.conf" +if [ -f "$session_env" ] && grep -qx "CLAUDE_CONFIG_DIR=$HOME/.claude-cliproxy" "$session_env"; then + pass "session CLAUDE_CONFIG_DIR points at the isolated Claude home" +else + fail "session CLAUDE_CONFIG_DIR is not $HOME/.claude-cliproxy (run ./install.sh claude)" +fi +live_claude_home="$(systemctl --user show-environment 2>/dev/null | sed -n 's/^CLAUDE_CONFIG_DIR=//p')" +if [ "$live_claude_home" = "$HOME/.claude-cliproxy" ]; then + pass "this login exports CLAUDE_CONFIG_DIR" +else + warn "this login does not export CLAUDE_CONFIG_DIR yet; log out and back in" +fi +[ "$(json_get "$T3_USERDATA/settings.json" providers.claudeAgent.homePath)" = "$HOME/.claude-cliproxy" ] && pass "T3 uses the Claude home" || fail "T3 settings do not point at $HOME/.claude-cliproxy" +[ "$(json_get "$T3_USERDATA/desktop-settings.json" tailscaleServeEnabled)" = true ] && pass "T3 Tailscale Serve enabled" || fail "T3 tailscaleServeEnabled is not true" + +echo "-- tailscale" +if command -v tailscale >/dev/null; then + state="$(tailscale status --json 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("BackendState",""))' 2>/dev/null)" + [ "$state" = Running ] && pass "tailscale running" || fail "tailscale state is '${state:-unknown}'" + serve="$(tailscale serve status 2>&1)" + grep -q "127.0.0.1:$T3_PORT" <<<"$serve" && pass "Serve publishes T3 (tailnet only)" || fail "Serve is not publishing 127.0.0.1:$T3_PORT" + ! grep -q 'Funnel on' <<<"$serve" && pass "Funnel off" || fail "Funnel is on" +else + fail "tailscale not installed" +fi + +echo +if [ "$failures" -eq 0 ]; then echo "all checks passed"; else echo "$failures check(s) failed"; fi +exit $((failures > 0)) diff --git a/coding-setup/cliproxy-console/.gitignore b/coding-setup/cliproxy-console/.gitignore new file mode 100644 index 0000000..902b281 --- /dev/null +++ b/coding-setup/cliproxy-console/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +data/ diff --git a/coding-setup/cliproxy-console/README.md b/coding-setup/cliproxy-console/README.md new file mode 100644 index 0000000..7b00371 --- /dev/null +++ b/coding-setup/cliproxy-console/README.md @@ -0,0 +1,69 @@ +# CLIProxy console + +Local board for a [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) instance. It shows the same three quota stages from Theo Browne’s Oct 2, 2026 video, [If you have a Claude sub, watch this](https://www.youtube.com/watch?v=D8PikZ1KhUo), and points Claude Code, OpenCode, and Cursor at one proxy. + +Theo’s on-screen dashboard is his private fork. He says a fresh install will not look like it. The stock UI is [Cli-Proxy-API-Management-Center](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) at `/management.html`. Other public tools cover parts of this (Quotio, Infinitus, CLIProxy Quota Tray, [quota-reset-router](https://github.com/WebDevCaptain/quota-reset-router)). None of them is his fork. + +## Run + +Normally it runs as the `cliproxy-console.service` user unit that `../install.sh console` installs. For development, stop the unit first, then: + +```bash +npm test +npm start +``` + +Open http://127.0.0.1:8787. Until you connect, the Quota page is sample data. + +## Connect your proxy + +CLIProxyAPI v8 listens on port 8317. A minimal `config.yaml`: + +```yaml +config-version: 8 +server: + host: "127.0.0.1" + port: 8317 +management: + allow-remote: false + secret-key: "replace-with-a-management-password" +access: + api-keys: + - "replace-with-the-key-clients-send" +routing: + strategy: "round-robin" + session-affinity: true + session-affinity-ttl: "1h" +oauth: + auth-dir: "~/.cli-proxy-api" +``` + +1. Start the proxy, then run `cli-proxy-api --claude-login` once for each Claude subscription. Each login writes another `claude-*.json` under `~/.cli-proxy-api`. +2. In this console, open Proxy, enter `http://127.0.0.1:8317` and the management key. + +The console calls `/v8/management` and falls back to `/v0/management` when v8 is absent. The management key is written to `data/settings.json` with mode `0600` and is not printed back. + +Docker, bound to localhost: + +```bash +docker run --rm -p 127.0.0.1:8317:8317 \ + -v "$PWD/config.yaml:/CLIProxyAPI/config.yaml" \ + -v "$HOME/.cli-proxy-api:/root/.cli-proxy-api" \ + eceasy/cli-proxy-api:latest +``` + +## Routing + +**Prefer soonest weekly reset** sets `routing.strategy` to `fill-first`, turns `session-affinity` on, and raises priority on the account whose 7-day window ends first. **Spread evenly** sets `round-robin` and leaves affinity on, so a thread that already started stays on its account. + +Codex websocket is a flag on each Codex auth file (`websockets: true`). The video’s point is that Codex subscriptions stay on HTTP until that is turned on. + +## Clients + +The Clients page prints the base URL and the `access.api-keys` value for: + +- Claude Code: `~/.claude/settings.json`, base URL with no `/v1` +- OpenCode: `opencode.json`, Anthropic base URL including `/v1` +- Cursor: Override OpenAI Base URL only. Cursor has no Anthropic base-URL setting, so Claude models inside Cursor stay on Anthropic. + +That client key is separate from the management key and from the Claude OAuth files. diff --git a/coding-setup/cliproxy-console/lib/consumption.mjs b/coding-setup/cliproxy-console/lib/consumption.mjs new file mode 100644 index 0000000..dff80c7 --- /dev/null +++ b/coding-setup/cliproxy-console/lib/consumption.mjs @@ -0,0 +1,119 @@ +/** + * Local consumption totals for clients that do not share Claude's /usage windows. + * Numbers come from each app's own session store or account API. They are not + * turned into a fake 5-hour bar. + */ + +export function formatCount(value) { + const n = Number(value); + if (!Number.isFinite(n)) return "—"; + const abs = Math.abs(n); + if (abs >= 1_000_000_000) return `${trim(n / 1_000_000_000)}B`; + if (abs >= 1_000_000) return `${trim(n / 1_000_000)}M`; + if (abs >= 10_000) return `${trim(n / 1_000)}K`; + return String(Math.round(n)); +} + +export function formatDollars(cents) { + const n = Number(cents); + if (!Number.isFinite(n)) return "—"; + return `$${(n / 100).toFixed(2)}`; +} + +function trim(n) { + const text = n.toFixed(1); + return text.endsWith(".0") ? text.slice(0, -2) : text; +} + +export function parseOpenCodeModel(value) { + if (value && typeof value === "object") { + return { + id: String(value.id || "unknown"), + provider: String(value.providerID || value.provider || "opencode"), + }; + } + try { + return parseOpenCodeModel(JSON.parse(String(value || ""))); + } catch { + return { id: String(value || "unknown"), provider: "opencode" }; + } +} + +/** Collapse OpenCode session rows into provider groups. Bedrock is split out. */ +export function summarizeOpenCode(rows) { + const buckets = new Map(); + for (const row of rows || []) { + const model = parseOpenCodeModel(row.model); + const provider = model.provider === "amazon-bedrock" ? "bedrock" : "opencode"; + const key = `${provider}\0${model.id}`; + const current = buckets.get(key) || { + provider, + label: model.id, + detail: model.provider, + sessions: 0, + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cost: 0, + }; + current.sessions += Number(row.sessions) || 0; + current.input += Number(row.input) || 0; + current.output += Number(row.output) || 0; + current.reasoning += Number(row.reasoning) || 0; + current.cacheRead += Number(row.cacheRead) || 0; + current.cost += Number(row.cost) || 0; + buckets.set(key, current); + } + const opencode = []; + const bedrock = []; + for (const item of buckets.values()) { + (item.provider === "bedrock" ? bedrock : opencode).push(item); + } + const byTokens = (a, b) => (b.input + b.output) - (a.input + a.output); + opencode.sort(byTokens); + bedrock.sort(byTokens); + return { opencode, bedrock }; +} + +export function summarizeCodex(rows) { + return (rows || []) + .filter((row) => row.model && Number(row.tokens) > 0) + .map((row) => ({ + label: String(row.model), + detail: String(row.provider || "openai"), + sessions: Number(row.sessions) || 0, + tokens: Number(row.tokens) || 0, + })) + .sort((a, b) => b.tokens - a.tokens); +} + +export function summarizeCursorEdits(rows) { + return (rows || []) + .filter((row) => row.model && row.model !== "default") + .map((row) => ({ + label: String(row.model), + edits: Number(row.edits) || 0, + })) + .sort((a, b) => b.edits - a.edits); +} + +export function summarizeCursorPlan(summary) { + const plan = summary?.individualUsage?.plan; + if (!plan) return null; + return { + membership: String(summary.membershipType || "plan"), + cycleStart: summary.billingCycleStart || null, + cycleEnd: summary.billingCycleEnd || null, + includedUsedCents: numberOrNull(plan.used), + includedLimitCents: numberOrNull(plan.limit), + bonusCents: numberOrNull(plan.breakdown?.bonus), + autoPercent: numberOrNull(plan.autoPercentUsed), + apiPercent: numberOrNull(plan.apiPercentUsed), + onDemandEnabled: Boolean(summary.individualUsage?.onDemand?.enabled), + }; +} + +function numberOrNull(value) { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/coding-setup/cliproxy-console/lib/consumption.test.mjs b/coding-setup/cliproxy-console/lib/consumption.test.mjs new file mode 100644 index 0000000..71134bc --- /dev/null +++ b/coding-setup/cliproxy-console/lib/consumption.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + formatCount, + formatDollars, + summarizeCodex, + summarizeCursorPlan, + summarizeOpenCode, +} from "./consumption.mjs"; + +test("formats large token totals without pretending they are percents", () => { + assert.equal(formatCount(282765802), "282.8M"); + assert.equal(formatCount(891629204), "891.6M"); + assert.equal(formatCount(810), "810"); + assert.equal(formatDollars(2000), "$20.00"); +}); + +test("splits Bedrock out of OpenCode and merges model variants", () => { + const { opencode, bedrock } = summarizeOpenCode([ + { model: { id: "gemini-3.8-flash", providerID: "google" }, sessions: 40, input: 100, output: 10, reasoning: 1, cacheRead: 5, cost: 1 }, + { model: { id: "gemini-3.8-flash", providerID: "google", variant: "high" }, sessions: 7, input: 50, output: 5, reasoning: 0, cacheRead: 1, cost: 0.5 }, + { model: '{"id":"nemotron-3-ultra-free","providerID":"opencode"}', sessions: 1, input: 20, output: 2, reasoning: 0, cacheRead: 0, cost: 0 }, + { model: { id: "global.openai.gpt-5.6-terra", providerID: "amazon-bedrock" }, sessions: 2, input: 0, output: 0, reasoning: 0, cacheRead: 0, cost: 0 }, + ]); + assert.equal(opencode.length, 2); + assert.equal(opencode[0].label, "gemini-3.8-flash"); + assert.equal(opencode[0].sessions, 47); + assert.equal(opencode[0].input, 150); + assert.equal(bedrock.length, 1); + assert.equal(bedrock[0].label, "global.openai.gpt-5.6-terra"); + assert.equal(bedrock[0].sessions, 2); +}); + +test("keeps Codex models that recorded tokens", () => { + const rows = summarizeCodex([ + { model: "gpt-5.4", provider: "openai", sessions: 253, tokens: 1000 }, + { model: null, provider: "openai", sessions: 279, tokens: 0 }, + ]); + assert.deepEqual(rows.map((row) => row.label), ["gpt-5.4"]); +}); + +test("reads Cursor's included allowance and the two percent meters", () => { + const plan = summarizeCursorPlan({ + membershipType: "pro", + billingCycleStart: "2026-09-29T11:04:25.000Z", + billingCycleEnd: "2026-10-29T11:04:25.000Z", + individualUsage: { + plan: { + used: 2000, + limit: 2000, + breakdown: { bonus: 6994 }, + autoPercentUsed: 19.2, + apiPercentUsed: 14, + }, + onDemand: { enabled: false }, + }, + }); + assert.equal(plan.membership, "pro"); + assert.equal(plan.includedUsedCents, 2000); + assert.equal(plan.bonusCents, 6994); + assert.equal(plan.autoPercent, 19.2); + assert.equal(plan.onDemandEnabled, false); +}); diff --git a/coding-setup/cliproxy-console/lib/normalize.mjs b/coding-setup/cliproxy-console/lib/normalize.mjs new file mode 100644 index 0000000..41f32dc --- /dev/null +++ b/coding-setup/cliproxy-console/lib/normalize.mjs @@ -0,0 +1,569 @@ +/** + * Pure helpers for the quota board. + * + * CLIProxy reports two clocks that matter, plus a third model-specific slice: + * weekly — the 7-day pool. New sessions should prefer the account whose + * weekly window ends soonest, so leftover quota is used before it + * disappears. + * 5-hour — a burst cap inside that week. An empty 5-hour window means skip + * the account until it refills, even if the week still has room. + * extra — a separate model bucket (Opus, or Codex's other window). Shown + * dimmed. It does not choose the account. + */ + +const WEEKLY_KEYS = /seven[_\s-]?day|7d|weekly|secondary[_\s-]?window/i; +const FIVE_HOUR_KEYS = /five[_\s-]?hour|5h|primary[_\s-]?window/i; + +export function maskName(name) { + const raw = String(name ?? ""); + return raw.replace(/[A-Za-z0-9._%+-]+@/g, (match) => { + const local = match.slice(0, -1); + const dash = local.lastIndexOf("-"); + const head = dash >= 0 ? local.slice(0, dash + 1) : ""; + const person = dash >= 0 ? local.slice(dash + 1) : local; + return `${head}${person.slice(0, 1)}•••@`; + }); +} + +export function remainingFrom(value) { + if (!value || typeof value !== "object") return null; + if (typeof value.remaining_percent === "number") { + return clampPercent(value.remaining_percent); + } + if (typeof value.remaining === "number" && value.remaining <= 1) { + return clampPercent(value.remaining * 100); + } + if (typeof value.used_percent === "number") { + return clampPercent(100 - value.used_percent); + } + if (typeof value.utilization === "number") { + const used = value.utilization <= 1 ? value.utilization * 100 : value.utilization; + return clampPercent(100 - used); + } + return null; +} + +export function resetFrom(value) { + if (!value || typeof value !== "object") return null; + const direct = + value.resets_at || + value.reset_at || + value.resetsAt || + value.resetAt || + null; + if (typeof direct === "string" && !Number.isNaN(Date.parse(direct))) return direct; + if (typeof direct === "number" && direct > 1_000_000_000) { + const ms = direct < 10_000_000_000 ? direct * 1000 : direct; + return new Date(ms).toISOString(); + } + const seconds = value.reset_after_seconds ?? value.resetAfterSeconds; + if (typeof seconds === "number" && seconds >= 0) { + return new Date(Date.now() + seconds * 1000).toISOString(); + } + return null; +} + +function clampPercent(n) { + if (!Number.isFinite(n)) return null; + return Math.max(0, Math.min(100, Math.round(n * 10) / 10)); +} + +function slotFor(key, provider) { + const name = String(key); + // The gray column is the separate Opus slice. Check it before the weekly + // pattern, because the key is often "seven_day_opus". + if (/opus/i.test(name)) return "extra"; + if (provider === "codex") { + if (/primary/i.test(name)) return "fiveHour"; + if (/secondary/i.test(name)) return "weekly"; + } + if (FIVE_HOUR_KEYS.test(name) && !WEEKLY_KEYS.test(name)) return "fiveHour"; + if (WEEKLY_KEYS.test(name)) return "weekly"; + if (FIVE_HOUR_KEYS.test(name)) return "fiveHour"; + return null; +} + +const SLOT_LABEL = { + weekly: "7-day", + fiveHour: "5-hour", + extra: "Other bucket", +}; + +/** + * Walk a usage payload and pull the three windows. + * Only objects that themselves carry a utilization field count, so nested + * wrappers are not double-counted. + */ +export function windowsFromUsage(provider, body) { + const found = []; + visit(body, (key, value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + const remaining = remainingFrom(value); + if (remaining == null) return; + const slot = slotFor(key, provider); + if (!slot) return; + found.push({ + slot, + label: slot === "extra" ? extraLabel(key) : SLOT_LABEL[slot], + rawKey: String(key), + remaining, + resetsAt: resetFrom(value), + }); + }); + + const bySlot = new Map(); + for (const window of found) { + if (!bySlot.has(window.slot)) bySlot.set(window.slot, window); + } + return ["weekly", "fiveHour", "extra"] + .map((slot) => bySlot.get(slot)) + .filter(Boolean); +} + +function extraLabel(key) { + const text = String(key).replace(/[_-]+/g, " "); + if (/opus/i.test(text)) return "Opus bucket"; + return "Other bucket"; +} + +const WINDOW_TITLES = { + five_hour: ["Current session", "session", "fiveHour"], + seven_day: ["Current week (all models)", "weekly", "weekly"], + seven_day_sonnet: ["Current week (Sonnet only)", "weekly", "extra"], + seven_day_opus: ["Current week (Opus only)", "weekly", "extra"], + seven_day_oauth_apps: ["Current week (OAuth apps)", "weekly", "extra"], + seven_day_cowork: ["Current week (Cowork)", "weekly", "extra"], +}; + +/** + * The same buckets Claude Code prints for `/usage`. + * `limits` is the categorized list (session, all-models week, Fable/Sonnet/Opus). + * Top-level windows fill anything that list left out. Null buckets are omitted. + */ +export function usageReport(provider, body) { + if (!body || typeof body !== "object") return { categories: [], extra: null }; + if (provider === "codex") return { categories: codexCategories(body), extra: null }; + return claudeReport(body); +} + +function claudeReport(body) { + const categories = []; + const seen = new Set(); + + if (Array.isArray(body.limits)) { + for (const limit of body.limits) { + const title = titleForLimit(limit); + if (!title || seen.has(title) || typeof limit.percent !== "number") continue; + seen.add(title); + categories.push(categoryFromUsed({ + title, + group: limit.group || "weekly", + slot: slotForLimit(limit), + used: limit.percent, + resetsAt: typeof limit.resets_at === "string" ? limit.resets_at : null, + active: limit.is_active !== false, + severity: limit.severity || "normal", + })); + } + } + + for (const [key, [title, group, slot]] of Object.entries(WINDOW_TITLES)) { + if (seen.has(title)) continue; + const value = body[key]; + if (!value || typeof value !== "object") continue; + const remaining = remainingFrom(value); + if (remaining == null) continue; + seen.add(title); + categories.push(categoryFromUsed({ + title, + group, + slot, + used: 100 - remaining, + resetsAt: resetFrom(value), + active: true, + severity: "normal", + })); + } + + const order = { fiveHour: 0, weekly: 1, extra: 2 }; + categories.sort((a, b) => (order[a.slot] ?? 9) - (order[b.slot] ?? 9) || a.title.localeCompare(b.title)); + return { categories, extra: extraUsageFrom(body.extra_usage) }; +} + +function titleForLimit(limit) { + if (limit?.kind === "session") return "Current session"; + if (limit?.kind === "weekly_all") return "Current week (all models)"; + if (limit?.kind === "weekly_scoped") { + const name = limit.scope?.model?.display_name || limit.scope?.model?.id; + return name ? `Current week (${name})` : "Current week (scoped)"; + } + return null; +} + +function slotForLimit(limit) { + if (limit?.kind === "session") return "fiveHour"; + if (limit?.kind === "weekly_all") return "weekly"; + return "extra"; +} + +function categoryFromUsed({ title, group, slot, used, resetsAt, active, severity }) { + const usedPercent = clampPercent(used); + return { + title, + group, + slot, + used: usedPercent, + remaining: usedPercent == null ? null : clampPercent(100 - usedPercent), + resetsAt: resetsAt || null, + active: Boolean(active), + severity: severity || "normal", + }; +} + +function extraUsageFrom(value) { + if (!value || typeof value !== "object") return null; + const utilization = typeof value.utilization === "number" ? clampPercent(value.utilization <= 1 && value.utilization > 0 ? value.utilization * 100 : value.utilization) : null; + return { + enabled: Boolean(value.is_enabled), + reason: typeof value.disabled_reason === "string" ? value.disabled_reason : "", + spendLimitReached: Boolean(value.spend_limit_reached), + utilization, + }; +} + +function codexCategories(body) { + return windowsFromUsage("codex", body).map((window) => categoryFromUsed({ + title: window.slot === "fiveHour" ? "Current session" : window.slot === "weekly" ? "Current week" : window.label, + group: window.slot === "fiveHour" ? "session" : "weekly", + slot: window.slot, + used: 100 - window.remaining, + resetsAt: window.resetsAt, + active: true, + severity: "normal", + })); +} + +/** Ranking still uses the all-models week and the session cap, not a model slice. */ +export function windowsFromCategories(categories) { + const picked = []; + for (const slot of ["weekly", "fiveHour"]) { + const match = (categories || []).find((item) => item.slot === slot && item.remaining != null); + if (match) { + picked.push({ + slot, + label: match.title, + remaining: match.remaining, + resetsAt: match.resetsAt, + }); + } + } + return picked; +} + +function visit(node, fn, key = "") { + if (!node || typeof node !== "object") return; + if (!Array.isArray(node)) fn(key, node); + for (const [childKey, child] of Object.entries(node)) { + if (child && typeof child === "object") visit(child, fn, childKey); + } +} + +export function parseUsageBody(text) { + if (text == null || text === "") return null; + if (typeof text === "object") return text; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +export function filesFromList(json) { + if (!json) return []; + if (Array.isArray(json)) return json; + for (const key of ["files", "credentials", "items"]) { + if (Array.isArray(json[key])) return json[key]; + } + return []; +} + +export function providerOf(file) { + const provider = String(file?.provider || file?.type || "").toLowerCase(); + if (provider) return provider; + const name = String(file?.name || "").toLowerCase(); + if (name.startsWith("claude")) return "claude"; + if (name.startsWith("codex")) return "codex"; + if (name.includes("kimi")) return "kimi"; + if (name.includes("grok") || name.includes("xai")) return "xai"; + if (name.includes("antigravity")) return "antigravity"; + return "other"; +} + +export function decorateAccount(file, windows, extras = {}) { + const weekly = windows.find((item) => item.slot === "weekly") || null; + const fiveHour = windows.find((item) => item.slot === "fiveHour") || null; + const exhausted = + (weekly?.remaining === 0) || (fiveHour?.remaining === 0); + const resetMs = weekly?.resetsAt ? Date.parse(weekly.resetsAt) : NaN; + const email = String(file?.email || file?.label || extras.email || ""); + const plan = extras.plan || file?.subscription_type || ""; + const tier = CLAUDE_TIERS[extras.tier] || null; + return { + name: String(file?.name || file?.id || "account"), + displayName: email || maskName(file?.name || file?.id || "account"), + email, + organization: extras.organization || "", + provider: providerOf(file), + authIndex: file?.auth_index || file?.authIndex || "", + plan: plan && plan !== "oauth" ? String(plan) : "", + capacity: tier?.capacity ?? null, + tierLabel: tier?.label ?? "", + tierError: extras.tierError || "", + accountType: accountTypeLabel({ + provider: providerOf(file), + apiKey: extras.apiKey, + tier: extras.tier, + orgType: extras.orgType, + plan: plan && plan !== "oauth" ? String(plan) : "", + }), + disabled: Boolean(file?.disabled), + unavailable: Boolean(file?.unavailable), + cooldownCount: Array.isArray(file?.cooldowns) ? file.cooldowns.length : 0, + status: file?.disabled ? "off" : file?.unavailable ? "down" : file?.status || "ready", + windows, + categories: extras.categories || [], + extraUsage: extras.extraUsage || null, + exhausted, + weeklyResetMs: Number.isFinite(resetMs) ? resetMs : null, + }; +} + +/** + * Weekly capacity of each Claude plan as a multiple of Pro, keyed by the + * organization rate_limit_tier from the OAuth profile. + */ +export const CLAUDE_TIERS = { + default_claude_ai: { capacity: 1, label: "Pro" }, + default_claude_pro: { capacity: 1, label: "Pro" }, + default_claude_max_5x: { capacity: 5, label: "Max 5x" }, + default_claude_max_20x: { capacity: 20, label: "Max 20x" }, +}; + +/** + * Share of the pooled weekly quota still left across a provider's accounts, + * weighting each account's remaining percentage by its plan capacity. Two + * Max 5x accounts at 0% and 50% left pool to 25%. Null when any counted + * account has no weekly window or no known plan size, because the pool + * cannot be sized then and a partial number would be wrong. + */ +export function pooledWeekly(accounts) { + const pool = accounts.filter((account) => !account.disabled); + const weeklyOf = (account) => account.windows.find((item) => item.slot === "weekly")?.remaining; + if (!pool.length || pool.some((account) => !account.capacity || typeof weeklyOf(account) !== "number")) { + return null; + } + const total = pool.reduce((sum, account) => sum + account.capacity, 0); + const left = pool.reduce((sum, account) => sum + account.capacity * weeklyOf(account), 0); + return Math.round(left / total); +} + +const CLAUDE_ORG_TYPES = { + claude_team: "Team", + claude_enterprise: "Enterprise", + claude_max: "Max", + claude_pro: "Pro", +}; + +/** + * Human label for what kind of login an account is: the Claude plan tier and + * organization type, an API key, or the plan a provider reports. Unrecognised + * values are shown as the provider sent them. + */ +export function accountTypeLabel({ provider, apiKey, tier, orgType, plan }) { + if (apiKey) return "API key"; + if (provider === "claude") { + const size = CLAUDE_TIERS[tier]?.label || tier || ""; + const org = CLAUDE_ORG_TYPES[orgType] || orgType || ""; + return [size, org].filter(Boolean).join(" · ") || plan || ""; + } + return plan ? plan.charAt(0).toUpperCase() + plan.slice(1) : ""; +} + +export function orgTypeFromProfile(body) { + const type = body?.organization?.organization_type; + return typeof type === "string" ? type : ""; +} + +export function planFromUsage(provider, body) { + const plan = provider === "codex" ? body?.plan_type : ""; + return typeof plan === "string" ? plan : ""; +} + +/** Why a Claude plan tier from the OAuth profile cannot be used, or "" when it can. */ +export function tierProblem(tier) { + if (!tier) return "profile response had no organization.rate_limit_tier"; + if (!CLAUDE_TIERS[tier]) return `unknown plan tier "${tier}"; add it to CLAUDE_TIERS`; + return ""; +} + +/** + * Everything that keeps a Claude pool from being computed: a failed usage or + * profile call, a response without a 7-day window, an unrecognised plan tier. + * Shown on the card so an Anthropic API change is loud, not a silent dash. + */ +export function poolProblems(accounts) { + const problems = []; + for (const account of accounts) { + if (account.provider !== "claude" || account.disabled) continue; + const who = account.displayName || account.name; + if (account.quotaError) problems.push(`${who}: usage call failed (${account.quotaError})`); + else if (!account.windows.some((item) => item.slot === "weekly")) { + problems.push(`${who}: usage response had no 7-day window`); + } + if (account.tierError) problems.push(`${who}: ${account.tierError}`); + } + return problems; +} + +/** "2 × Max 5x" style description of the accounts that make up the pool. */ +export function poolLabel(accounts) { + const counts = new Map(); + for (const account of accounts.filter((item) => !item.disabled && item.tierLabel)) { + counts.set(account.tierLabel, (counts.get(account.tierLabel) || 0) + 1); + } + return [...counts.entries()].map(([label, count]) => `${count} × ${label}`).join(" + "); +} + +/** + * Accounts that still have both the weekly and 5-hour windows open, ordered + * by the soonest weekly reset. That first account is where a new session goes + * when routing is set to fill-first. + */ +export function rankForSoonestReset(accounts) { + return [...accounts].sort((a, b) => { + if (a.exhausted !== b.exhausted) return a.exhausted ? 1 : -1; + const aReset = a.weeklyResetMs ?? Number.POSITIVE_INFINITY; + const bReset = b.weeklyResetMs ?? Number.POSITIVE_INFINITY; + if (aReset !== bReset) return aReset - bReset; + return a.name.localeCompare(b.name); + }); +} + +export function prioritiesFor(accounts) { + const ranked = rankForSoonestReset(accounts).filter((account) => !account.disabled); + return ranked.map((account, index) => ({ + name: account.name, + priority: Math.max(1, 100 - index * 10), + preferred: index === 0 && !account.exhausted && account.weeklyResetMs != null, + })); +} + +/** + * Rewrite only the routing strategy and session-affinity lines. + * Leaves every other key, including anything under `codex:`, untouched. + */ +export function patchRoutingYaml(yaml, { strategy, sessionAffinity }) { + const text = String(yaml ?? ""); + if (/^routing:[ \t]+\S/m.test(text)) { + return { ok: false, reason: "inline-routing", yaml: text }; + } + const lines = text.split("\n"); + let inRouting = false; + let sawStrategy = false; + let sawAffinity = false; + const out = []; + + for (const line of lines) { + if (/^routing:\s*$/.test(line)) { + inRouting = true; + out.push(line); + continue; + } + if (inRouting && line.trim() !== "" && !/^\s/.test(line)) { + if (!sawStrategy) out.push(` strategy: "${strategy}"`); + if (!sawAffinity) out.push(` session-affinity: ${Boolean(sessionAffinity)}`); + sawStrategy = true; + sawAffinity = true; + inRouting = false; + } + if (inRouting && /^\s+strategy:/.test(line)) { + out.push(` strategy: "${strategy}"`); + sawStrategy = true; + continue; + } + if (inRouting && /^\s+session-affinity:/.test(line)) { + out.push(` session-affinity: ${Boolean(sessionAffinity)}`); + sawAffinity = true; + continue; + } + out.push(line); + } + + if (inRouting) { + if (!sawStrategy) out.push(` strategy: "${strategy}"`); + if (!sawAffinity) out.push(` session-affinity: ${Boolean(sessionAffinity)}`); + } + + if (!/^routing:\s*$/m.test(text)) { + const addition = [ + "", + "routing:", + ` strategy: "${strategy}"`, + ` session-affinity: ${Boolean(sessionAffinity)}`, + ` session-affinity-ttl: "1h"`, + "", + ]; + return { ok: true, appended: true, yaml: `${text.replace(/\s*$/, "")}\n${addition.join("\n")}` }; + } + + return { ok: true, appended: false, yaml: out.join("\n") }; +} + +export function formatReset(iso, now = Date.now()) { + if (!iso) return "reset time unknown"; + const ms = Date.parse(iso) - now; + if (!Number.isFinite(ms)) return "reset time unknown"; + if (ms <= 0) return "reset due"; + const minutes = Math.round(ms / 60000); + if (minutes < 90) return `in ${minutes} min`; + const hours = Math.round(ms / 3600000); + if (hours < 20) return `in ${hours} hour${hours === 1 ? "" : "s"}`; + const days = Math.round(ms / 86400000); + return `in ${days} day${days === 1 ? "" : "s"}`; +} + +export const USAGE_URL = { + claude: "https://api.anthropic.com/api/oauth/usage", + codex: "https://chatgpt.com/backend-api/wham/usage", +}; + +export function profileRequest(provider) { + if (provider !== "claude") return null; + return { + method: "GET", + url: "https://api.anthropic.com/api/oauth/profile", + header: { + Authorization: "Bearer $TOKEN$", + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + Accept: "application/json", + }, + }; +} + +export function tierFromProfile(body) { + const tier = body?.organization?.rate_limit_tier; + return typeof tier === "string" ? tier : ""; +} + +export function usageRequest(provider) { + const url = USAGE_URL[provider]; + if (!url) return null; + const header = { Authorization: "Bearer $TOKEN$" }; + if (provider === "claude") { + header["anthropic-version"] = "2023-06-01"; + header["anthropic-beta"] = "oauth-2025-04-20"; + header.Accept = "application/json"; + } + return { method: "GET", url, header }; +} diff --git a/coding-setup/cliproxy-console/lib/normalize.test.mjs b/coding-setup/cliproxy-console/lib/normalize.test.mjs new file mode 100644 index 0000000..06d539c --- /dev/null +++ b/coding-setup/cliproxy-console/lib/normalize.test.mjs @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + accountTypeLabel, + decorateAccount, + filesFromList, + formatReset, + maskName, + patchRoutingYaml, + planFromUsage, + poolLabel, + poolProblems, + pooledWeekly, + tierProblem, + prioritiesFor, + rankForSoonestReset, + usageReport, + windowsFromCategories, + windowsFromUsage, +} from "./normalize.mjs"; + +test("reads either the v8 credentials list or the v0 auth-file list", () => { + assert.equal(filesFromList({ credentials: [{ name: "a.json" }] })[0].name, "a.json"); + assert.equal(filesFromList({ files: [{ name: "b.json" }] })[0].name, "b.json"); +}); + +test("masks the local part of an auth-file email", () => { + assert.equal(maskName("claude-alice@example.com.json"), "claude-a•••@example.com.json"); + assert.equal(maskName("codex-work.json"), "codex-work.json"); +}); + +test("reads Claude windows and parks Opus in the gray column", () => { + const windows = windowsFromUsage("claude", { + five_hour: { utilization: 0, resets_at: "2026-10-05T18:00:00Z" }, + seven_day: { utilization: 0.32, resets_at: "2026-10-06T17:00:00Z" }, + seven_day_opus: { utilization: 0.8, resets_at: "2026-10-08T17:00:00Z" }, + }); + assert.deepEqual( + windows.map((item) => [item.slot, item.remaining, item.label]), + [ + ["weekly", 68, "7-day"], + ["fiveHour", 100, "5-hour"], + ["extra", 20, "Opus bucket"], + ], + ); +}); + +test("categorizes Claude /usage limits the way the CLI labels them", () => { + const report = usageReport("claude", { + five_hour: { utilization: 3, resets_at: "2026-10-05T03:09:59Z" }, + seven_day: { utilization: 32, resets_at: "2026-10-10T17:59:59Z" }, + seven_day_opus: null, + seven_day_sonnet: null, + limits: [ + { kind: "session", group: "session", percent: 3, severity: "normal", resets_at: "2026-10-05T03:09:59Z", is_active: false }, + { kind: "weekly_all", group: "weekly", percent: 32, severity: "normal", resets_at: "2026-10-10T17:59:59Z", is_active: true }, + { + kind: "weekly_scoped", + group: "weekly", + percent: 0, + severity: "normal", + resets_at: "2026-10-10T18:00:00Z", + is_active: false, + scope: { model: { display_name: "Fable" } }, + }, + ], + extra_usage: { is_enabled: false, disabled_reason: "out_of_credits", utilization: null }, + }); + assert.deepEqual( + report.categories.map((item) => [item.title, item.used, item.remaining, item.active]), + [ + ["Current session", 3, 97, false], + ["Current week (all models)", 32, 68, true], + ["Current week (Fable)", 0, 100, false], + ], + ); + assert.equal(report.extra.enabled, false); + assert.equal(report.extra.reason, "out_of_credits"); + assert.deepEqual( + windowsFromCategories(report.categories).map((item) => item.slot), + ["weekly", "fiveHour"], + ); +}); + +test("treats Claude utilization above 1 as percent used", () => { + const windows = windowsFromUsage("claude", { + five_hour: { utilization: 0, resets_at: "2026-10-05T18:00:00Z" }, + seven_day: { utilization: 39, resets_at: "2026-10-07T01:00:00Z" }, + }); + assert.equal(windows.find((item) => item.slot === "weekly").remaining, 61); + assert.equal(windows.find((item) => item.slot === "fiveHour").remaining, 100); +}); + +test("reads Codex primary as 5-hour and secondary as weekly", () => { + const windows = windowsFromUsage("codex", { + rate_limit: { + primary_window: { used_percent: 0, reset_at: "2026-10-05T01:00:00Z" }, + secondary_window: { used_percent: 83, reset_at: "2026-10-09T01:00:00Z" }, + }, + }); + assert.equal(windows.find((item) => item.slot === "fiveHour").remaining, 100); + assert.equal(windows.find((item) => item.slot === "weekly").remaining, 17); +}); + +test("ranks the open account whose weekly window ends soonest", () => { + const soon = decorateAccount({ name: "claude-a.json", provider: "claude" }, [ + { slot: "weekly", remaining: 68, resetsAt: "2026-10-05T17:00:00Z" }, + { slot: "fiveHour", remaining: 100, resetsAt: "2026-10-05T01:00:00Z" }, + ]); + const later = decorateAccount({ name: "claude-b.json", provider: "claude" }, [ + { slot: "weekly", remaining: 100, resetsAt: "2026-10-08T17:00:00Z" }, + { slot: "fiveHour", remaining: 100, resetsAt: "2026-10-05T01:00:00Z" }, + ]); + const empty = decorateAccount({ name: "claude-c.json", provider: "claude" }, [ + { slot: "weekly", remaining: 0, resetsAt: "2026-10-04T18:00:00Z" }, + { slot: "fiveHour", remaining: 100, resetsAt: "2026-10-05T01:00:00Z" }, + ]); + const ranked = rankForSoonestReset([later, empty, soon]); + assert.deepEqual(ranked.map((item) => item.name), [ + "claude-a.json", + "claude-b.json", + "claude-c.json", + ]); + const priorities = prioritiesFor([later, empty, soon]); + assert.equal(priorities[0].name, "claude-a.json"); + assert.equal(priorities[0].priority, 100); + assert.equal(priorities[0].preferred, true); + assert.equal(priorities.at(-1).name, "claude-c.json"); +}); + +test("routing patch changes strategy and affinity only", () => { + const original = [ + "port: 8317", + "routing:", + ' strategy: "round-robin"', + " session-affinity: false", + ' session-affinity-ttl: "1h"', + "codex:", + " identity-confuse: false", + " disable-codex-cloaking: false", + "", + ].join("\n"); + const patched = patchRoutingYaml(original, { + strategy: "fill-first", + sessionAffinity: true, + }); + assert.equal(patched.ok, true); + assert.match(patched.yaml, /strategy: "fill-first"/); + assert.match(patched.yaml, /session-affinity: true/); + assert.match(patched.yaml, /identity-confuse: false/); + assert.match(patched.yaml, /disable-codex-cloaking: false/); + assert.doesNotMatch(patched.yaml, /round-robin/); +}); + +test("refuses to rewrite an inline routing line", () => { + const result = patchRoutingYaml('routing: { strategy: "round-robin" }\n', { + strategy: "fill-first", + sessionAffinity: true, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, "inline-routing"); +}); + +test("describes a reset that is a day away", () => { + const now = Date.parse("2026-10-04T17:00:00Z"); + assert.equal(formatReset("2026-10-05T17:00:00Z", now), "in 1 day"); + assert.equal(formatReset("2026-10-08T17:00:00Z", now), "in 4 days"); +}); + +test("pools weekly quota across accounts weighted by plan capacity", () => { + const account = (remaining, tier, extra = {}) => + decorateAccount({ name: `a${remaining}`, type: "claude", ...extra }, [{ slot: "weekly", remaining }], { tier }); + const max5 = "default_claude_max_5x"; + assert.equal(pooledWeekly([account(0, max5, { unavailable: true }), account(50, max5)]), 25); + assert.equal(pooledWeekly([account(0, max5), account(50, "default_claude_max_20x")]), 40); + assert.equal(pooledWeekly([account(80, max5), account(50, max5, { disabled: true })]), 80); + assert.equal(poolLabel([account(0, max5), account(50, max5)]), "2 × Max 5x"); +}); + +test("pooled weekly quota is unknown when an account's plan size is unknown", () => { + const account = (remaining, tier) => + decorateAccount({ name: "a", type: "claude" }, [{ slot: "weekly", remaining }], { tier }); + assert.equal(pooledWeekly([account(0, "default_claude_max_5x"), account(50, "")]), null); + assert.equal(pooledWeekly([]), null); +}); + +test("pooled weekly quota is unknown when an enabled account returned no weekly window", () => { + const withWindow = decorateAccount({ name: "a", type: "claude" }, [{ slot: "weekly", remaining: 50 }], { tier: "default_claude_max_5x" }); + const without = decorateAccount({ name: "b", type: "claude" }, [], { tier: "default_claude_max_5x" }); + assert.equal(pooledWeekly([withWindow, without]), null); + assert.equal(pooledWeekly([withWindow, { ...without, disabled: true }]), 50); +}); + +test("reports every reason a Claude pool cannot be computed", () => { + const ok = decorateAccount({ name: "ok", type: "claude", email: "ok@example.com" }, [{ slot: "weekly", remaining: 50 }], { tier: "default_claude_max_5x" }); + assert.deepEqual(poolProblems([ok]), []); + const failed = { ...ok, displayName: "failed@example.com", windows: [], quotaError: "usage endpoint returned 401" }; + const shapeChange = { ...ok, displayName: "shape@example.com", windows: [{ slot: "fiveHour", remaining: 90 }] }; + const badTier = { ...ok, displayName: "tier@example.com", tierError: tierProblem("default_claude_max_50x") }; + assert.deepEqual(poolProblems([failed, shapeChange, badTier]), [ + "failed@example.com: usage call failed (usage endpoint returned 401)", + "shape@example.com: usage response had no 7-day window", + 'tier@example.com: unknown plan tier "default_claude_max_50x"; add it to CLAUDE_TIERS', + ]); + assert.deepEqual(poolProblems([{ ...failed, disabled: true }]), []); + assert.deepEqual(poolProblems([{ ...failed, provider: "codex" }]), []); +}); + +test("tierProblem accepts known tiers and rejects empty or unknown ones", () => { + assert.equal(tierProblem("default_claude_max_20x"), ""); + assert.match(tierProblem(""), /no organization\.rate_limit_tier/); + assert.match(tierProblem("brand_new_tier"), /unknown plan tier/); +}); + +test("labels what kind of login each account is", () => { + assert.equal(accountTypeLabel({ provider: "claude", tier: "default_claude_max_5x", orgType: "claude_team" }), "Max 5x · Team"); + assert.equal(accountTypeLabel({ provider: "claude", tier: "default_claude_max_20x", orgType: "claude_enterprise" }), "Max 20x · Enterprise"); + assert.equal(accountTypeLabel({ provider: "claude", tier: "future_tier", orgType: "claude_new" }), "future_tier · claude_new"); + assert.equal(accountTypeLabel({ provider: "claude", apiKey: true }), "API key"); + assert.equal(accountTypeLabel({ provider: "codex", plan: "plus" }), "Plus"); + assert.equal(accountTypeLabel({ provider: "codex" }), ""); + assert.equal(planFromUsage("codex", { plan_type: "pro" }), "pro"); + assert.equal(planFromUsage("claude", { plan_type: "pro" }), ""); +}); diff --git a/coding-setup/cliproxy-console/package.json b/coding-setup/cliproxy-console/package.json new file mode 100644 index 0000000..323f13b --- /dev/null +++ b/coding-setup/cliproxy-console/package.json @@ -0,0 +1,10 @@ +{ + "name": "cliproxy-console", + "private": true, + "type": "module", + "description": "Local quota board for CLIProxyAPI, matching the account windows from Theo's coding-setup video.", + "scripts": { + "start": "node server.mjs", + "test": "node --test" + } +} diff --git a/coding-setup/cliproxy-console/public/app.js b/coding-setup/cliproxy-console/public/app.js new file mode 100644 index 0000000..56317ce --- /dev/null +++ b/coding-setup/cliproxy-console/public/app.js @@ -0,0 +1,628 @@ +const main = document.querySelector("#main"); +const navButtons = [...document.querySelectorAll(".nav-btn")]; + +const pages = ["quota", "routing", "clients", "logs", "proxy"]; +const requested = new URLSearchParams(location.search).get("page"); +const state = { + page: pages.includes(requested) ? requested : "quota", + filter: "all", + status: { connected: false }, + board: null, + consumption: null, + error: "", + routingNote: "", + logs: "", + busy: false, + clientKey: localStorage.getItem("cliproxy-console-client-key") || "", +}; + +navButtons.forEach((button) => { + button.addEventListener("click", () => { + state.page = button.dataset.page; + render(); + if (state.page === "logs") loadLogs(); + if (state.page === "quota") loadBoard(); + }); +}); + +function hoursFromNow(hours) { + return new Date(Date.now() + hours * 3600000).toISOString(); +} + +function sampleBoard() { + const accounts = [ + account("claude", "claude-a•••@example.com.json", "Max", 68, 24, 100, 5, 20, 72), + account("claude", "claude-a•••@example.com.json", "Max", 100, 96, 100, 5, 100, 96), + account("claude", "claude-a•••@example.com.json", "Max", 100, 120, 100, 5, 100, 120), + account("claude", "claude-a•••@example.com.json", "Max", 81, 28, 99, 4, 70, 48), + account("codex", "codex-a•••@example.com.json", "Plus", 17, 80, 100, 3, null, null), + ]; + return { source: "demo", accounts, groups: group(accounts) }; +} + +function account(provider, name, plan, weekly, weeklyHours, five, fiveHours, extra, extraHours) { + const windows = [ + { slot: "weekly", label: "Current week (all models)", remaining: weekly, resetsAt: hoursFromNow(weeklyHours) }, + { slot: "fiveHour", label: "Current session", remaining: five, resetsAt: hoursFromNow(fiveHours) }, + ]; + const categories = [ + meter("Current session", "session", "fiveHour", 100 - five, five, hoursFromNow(fiveHours), true), + meter("Current week (all models)", "weekly", "weekly", 100 - weekly, weekly, hoursFromNow(weeklyHours), true), + ]; + if (extra != null) { + windows.push({ + slot: "extra", + label: "Current week (Opus only)", + remaining: extra, + resetsAt: hoursFromNow(extraHours), + }); + categories.push(meter("Current week (Opus only)", "weekly", "extra", 100 - extra, extra, hoursFromNow(extraHours), true)); + } + return { + name, + displayName: name, + email: name, + organization: "", + provider, + plan, + accountType: provider === "claude" ? "Max 5x" : plan, + capacity: provider === "claude" ? 5 : null, + tierLabel: provider === "claude" ? "Max 5x" : "", + status: "ready", + disabled: false, + cooldownCount: 0, + exhausted: weekly === 0 || five === 0, + weeklyResetMs: Date.parse(windows[0].resetsAt), + windows, + categories, + extraUsage: { enabled: false, reason: "out_of_credits", spendLimitReached: false, utilization: null }, + quotaError: "", + }; +} + +function meter(title, group, slot, used, remaining, resetsAt, active) { + return { title, group, slot, used, remaining, resetsAt, active, severity: "normal" }; +} + +function pooledWeekly(items) { + const weekly = (item) => item.windows.find((w) => w.slot === "weekly").remaining; + if (items.some((item) => !item.capacity)) return null; + const total = items.reduce((sum, item) => sum + item.capacity, 0); + return Math.round(items.reduce((sum, item) => sum + item.capacity * weekly(item), 0) / total); +} + +function poolLabel(items) { + const counts = new Map(); + for (const item of items) counts.set(item.tierLabel, (counts.get(item.tierLabel) || 0) + 1); + return [...counts.entries()].map(([label, count]) => `${count} × ${label}`).join(" + "); +} + +function group(accounts) { + const map = new Map(); + for (const item of accounts) { + if (!map.has(item.provider)) map.set(item.provider, []); + map.get(item.provider).push(item); + } + return [...map.entries()].map(([provider, items]) => ({ + provider, + pooledWeekly: pooledWeekly(items), + poolLabel: poolLabel(items), + problems: [], + accounts: items, + })); +} + +function formatReset(iso) { + if (!iso) return "reset time unknown"; + const ms = Date.parse(iso) - Date.now(); + if (!Number.isFinite(ms)) return "reset time unknown"; + if (ms <= 0) return "reset due"; + const minutes = Math.round(ms / 60000); + if (minutes < 90) return `in ${minutes} min`; + const hours = Math.round(ms / 3600000); + if (hours < 20) return `in ${hours} hour${hours === 1 ? "" : "s"}`; + const days = Math.round(ms / 86400000); + return `in ${days} day${days === 1 ? "" : "s"}`; +} + +function tone(remaining) { + if (remaining == null) return ""; + if (remaining < 20) return "low"; + if (remaining < 100) return "mid"; + return ""; +} + +function preferredName(board) { + const open = (board?.accounts || []).filter((item) => !item.exhausted && item.weeklyResetMs); + open.sort((a, b) => a.weeklyResetMs - b.weeklyResetMs); + return open[0]?.name || ""; +} + +async function loadStatus() { + const response = await fetch("/api/status"); + state.status = await response.json(); +} + +async function loadBoard() { + state.busy = true; + render(); + try { + const [response, usageResponse] = await Promise.all([ + fetch("/api/board"), + fetch("/api/consumption"), + ]); + const body = await response.json(); + if (!response.ok) throw new Error(body.error || "Could not read the proxy"); + state.board = body.source === "live" ? body : sampleBoard(); + state.consumption = usageResponse.ok ? await usageResponse.json() : null; + state.error = ""; + } catch (error) { + state.error = error.message; + state.board = sampleBoard(); + } finally { + state.busy = false; + render(); + } +} + +async function loadLogs() { + const response = await fetch("/api/logs"); + const body = await response.json(); + state.logs = body.error + ? body.error + : (body.lines || []).join("\n") || "No log lines yet. Connect the proxy, then come back."; + if (state.page === "logs") render(); +} + +function proxyBase() { + return state.status.baseUrl || "http://127.0.0.1:8317"; +} + +function render() { + navButtons.forEach((button) => { + button.classList.toggle("active", button.dataset.page === state.page); + }); + const pages = { quota: renderQuota, routing: renderRouting, clients: renderClients, logs: renderLogs, proxy: renderProxy }; + main.innerHTML = pages[state.page](); + bind(); +} + +function renderQuota() { + const board = state.board || sampleBoard(); + const providers = ["all", ...new Set([ + ...board.accounts.map((item) => item.provider), + ...(state.consumption?.groups || []).map((item) => item.id), + ])]; + const next = preferredName(board); + const groups = (board.groups || group(board.accounts)).filter( + (item) => state.filter === "all" || item.provider === state.filter, + ); + const banner = board.source === "demo" + ? `` + : ""; + const apiProblems = (board.groups || []).flatMap((item) => item.problems || []); + const error = [ + state.error, + apiProblems.length ? `Quota data is incomplete. The provider API may have changed: ${apiProblems.join("; ")}` : "", + ].filter(Boolean).map((text) => ``).join(""); + return ` +

Quota

+

Each account shows the same buckets as /usage: the current session, the week across all models, and any model week Claude reports. A thread stays put until its account is actually out of quota.

+ ${banner}${error} +
+
+ ${providers.map((provider) => ``).join("")} +
+ +
+
+ ${groups.slice(0, 4).map(summaryCard).join("")} +
+ ${groups.map((item) => renderGroup(item, next)).join("")} + ${renderConsumption()} +
+

What the rows mean

+
+
Current session The 5-hour burst cap. This is the first meter /usage prints.
+
Current week (all models) The shared 7-day pool. When it ends, what is left is gone.
+
Current week (Fable, Sonnet, Opus) A model-only slice, shown only when Claude sends one. A slice that is not counting yet is dimmed. It does not choose the account.
+
Extra usage The credit balance /usage shows after the plan windows. Off means the plan limits are the only cap.
+
Quota failover A thread keeps its account, including the prompt cache, until that account returns a quota error. The proxy then retries that request on the other account and the thread stays on the new one. If both are out, the thread stops.
+
Resets first The bright row is the open account whose weekly window ends soonest. Round-robin still spreads brand-new threads. Fill-first, on the Routing page, spends this row first.
+
+
+ `; +} + +function renderConsumption() { + const groups = (state.consumption?.groups || []).filter( + (item) => state.filter === "all" || item.id === state.filter, + ); + if (!groups.length) return ""; + return ` +

Other clients

+ ${groups.map(renderConsumptionGroup).join("")} + `; +} + +function renderConsumptionGroup(group) { + const rows = group.id === "cursor" ? renderCursor(group) : (group.rows || []).map(renderSpendRow).join(""); + return ` +

${escapeHtml(group.title)}${group.accountType ? ` ${escapeHtml(group.accountType)}` : ""}

+

${escapeHtml(group.note || "")}

+ ${rows || `

Nothing recorded.

`} + `; +} + +function renderSpendRow(row) { + const bits = []; + if (row.tokens != null) bits.push(["Tokens recorded", formatCount(row.tokens)]); + if (row.input != null) bits.push(["Input", formatCount(row.input)]); + if (row.output != null) bits.push(["Output", formatCount(row.output)]); + if (row.cacheRead) bits.push(["Cache read", formatCount(row.cacheRead)]); + if (row.reasoning) bits.push(["Reasoning", formatCount(row.reasoning)]); + if (row.cost) bits.push(["Cost recorded", `$${Number(row.cost).toFixed(2)}`]); + if (row.edits != null) bits.push(["Editor edits", formatCount(row.edits)]); + const meters = bits.map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(value)}
`).join(""); + const detail = [ + row.sessions != null ? `${row.sessions} session${row.sessions === 1 ? "" : "s"}` : "", + row.detail && row.detail !== row.label ? row.detail : "", + ].filter(Boolean).join(" · "); + return ` + + `; +} + +function renderCursor(group) { + const plan = group.plan; + const edits = (group.edits || []).map(renderSpendRow).join(""); + if (!plan) return edits; + const included = plan.includedLimitCents + ? Math.round((plan.includedUsedCents / plan.includedLimitCents) * 100) + : null; + const cycle = [formatDay(plan.cycleStart), formatDay(plan.cycleEnd)].filter(Boolean).join(" – "); + const meters = [ + meterLine("Included allowance", included, `${formatDollars(plan.includedUsedCents)} of ${formatDollars(plan.includedLimitCents)}`), + meterLine("Auto models", plan.autoPercent, `${Math.round(plan.autoPercent)}% used`), + meterLine("API models", plan.apiPercent, `${Math.round(plan.apiPercent)}% used`), + ].join(""); + const bonus = plan.bonusCents + ? `
Bonus usage${escapeHtml(formatDollars(plan.bonusCents))}
` + : ""; + return ` + + ${edits} + `; +} + +function meterLine(title, percent, caption) { + const width = percent == null ? 0 : Math.max(0, Math.min(100, percent)); + return `
${escapeHtml(title)}${escapeHtml(caption)}
`; +} + +function formatCount(value) { + const n = Number(value); + if (!Number.isFinite(n)) return "—"; + const abs = Math.abs(n); + if (abs >= 1_000_000_000) return `${trimCount(n / 1_000_000_000)}B`; + if (abs >= 1_000_000) return `${trimCount(n / 1_000_000)}M`; + if (abs >= 10_000) return `${trimCount(n / 1_000)}K`; + return String(Math.round(n)); +} + +function trimCount(n) { + const text = n.toFixed(1); + return text.endsWith(".0") ? text.slice(0, -2) : text; +} + +function formatDollars(cents) { + const n = Number(cents); + if (!Number.isFinite(n)) return "—"; + return `$${(n / 100).toFixed(2)}`; +} + +function formatDay(iso) { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +function summaryCard(group) { + if (group.problems?.length) { + return `
${escapeHtml(group.provider)}
Unavailable
${group.problems.map(escapeHtml).join("
")}
`; + } + const left = group.pooledWeekly; + const used = left == null ? null : 100 - left; + const shown = used == null ? "—" : `${used}%`; + const sub = used == null ? `no pooled 7-day view for ${escapeHtml(group.provider)}` : `pooled 7-day used · ${escapeHtml(group.poolLabel)}`; + return `
${escapeHtml(group.provider)}
${shown}
${sub}
`; +} + +function renderGroup(group, next) { + return ` +

${escapeHtml(group.provider)}

+ ${group.accounts.map((account) => renderAccount(account, account.name === next)).join("")} + `; +} + +function renderAccount(account, isNext) { + const categories = account.categories?.length + ? account.categories + : (account.windows || []).map((window) => ({ + title: window.label, + used: window.remaining == null ? null : Math.max(0, 100 - window.remaining), + remaining: window.remaining, + resetsAt: window.resetsAt, + active: window.slot !== "extra", + severity: "normal", + })); + const meters = categories.map(renderMeter).join(""); + const extra = extraLine(account.extraUsage); + return ` + + `; +} + +function renderMeter(category) { + const quiet = category.slot === "extra" && category.active === false; + const used = category.used == null ? "—" : `${category.used}% used`; + const left = category.remaining == null ? "" : `${category.remaining}% left`; + const reset = category.resetsAt + ? formatReset(category.resetsAt) + : category.used + ? "reset time unknown" + : "not started"; + const when = quiet + ? "not counting yet" + : [ + left, + reset, + category.severity && category.severity !== "normal" ? category.severity : "", + ].filter(Boolean).join(" · "); + return `
${escapeHtml(category.title)}${escapeHtml(used)}
${escapeHtml(when)}
`; +} + +function extraLine(extra) { + if (!extra) return ""; + let state = "not reported"; + if (extra.enabled) { + state = extra.utilization == null ? "on" : `on · ${extra.utilization}% used`; + } else if (extra.spendLimitReached) { + state = "spend limit reached"; + } else if (extra.reason === "out_of_credits") { + state = "off · no credits loaded"; + } else if (extra.reason) { + state = `off · ${extra.reason.replaceAll("_", " ")}`; + } else { + state = "off"; + } + return `
Extra usage${escapeHtml(state)}
`; +} + +function renderRouting() { + return ` +

Routing

+

Round-robin spreads new sessions across accounts. Fill-first spends the account whose weekly window ends soonest. Either way, a thread stays on its account until that account hits quota, then the same request is retried on the other one.

+ ${state.routingNote ? `` : ""} +
+

Where a new session goes

+
+ + +
+

Soonest-reset writes routing.strategy: fill-first, turns session affinity on, and sets a higher priority on the account that resets first. Spreading evenly switches the strategy back to round-robin and leaves affinity on. Affinity holds a thread to one account. A quota error makes that account unavailable, the proxy retries the request on another account (up to three extra rounds), and the thread then stays on the account it moved to.

+
+
+

Codex websocket

+

Codex subscriptions do not use the websocket transport unless the credential asks for it. Turning it on shortens the round trip. This only patches Codex auth files.

+
+ + +
+
+ `; +} + +function renderClients() { + const base = proxyBase(); + const key = state.clientKey || "YOUR_PROXY_API_KEY"; + return ` +

Clients

+

All three use the client key in access.api-keys. That is not the management key, and it is not a Claude OAuth token.

+
+ + +
+
+

Claude Code

+

In ~/.claude/settings.json. The base URL has no /v1; Claude Code adds /v1/messages itself. Sign each subscription in with cli-proxy-api --claude-login, once per account.

+
${escapeHtml(`{
+  "env": {
+    "ANTHROPIC_BASE_URL": "${base}",
+    "ANTHROPIC_AUTH_TOKEN": "${key}",
+    "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1"
+  }
+}`)}
+
+
+

OpenCode

+

In ~/.config/opencode/opencode.json. OpenCode's Anthropic provider appends /messages, so its base URL includes /v1. The OpenAI provider uses the same /v1 prefix.

+
${escapeHtml(opencodeSnippet(base, key))}
+
+
+

Cursor Agent

+

Cursor has no Anthropic base-URL override, so Claude models in Cursor stay on Anthropic's own host. The path that does work is Settings, Models, API Keys, Override OpenAI Base URL. While that override is on, OpenAI-family requests go to the proxy. Turn it off when you want Cursor's own subscription models. Add a custom model whose id is one returned by GET /v1/models.

+
${escapeHtml(`Override OpenAI Base URL: ${base}/v1
+OpenAI API Key:             ${key}`)}
+
+ `; +} + +function opencodeSnippet(base, key) { + return JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + anthropic: { + options: { baseURL: `${base}/v1`, apiKey: key }, + }, + openai: { + options: { baseURL: `${base}/v1`, apiKey: key }, + }, + }, + }, null, 2); +} + +function renderLogs() { + return ` +

Logs

+

Recent lines from the proxy management log.

+
+
${escapeHtml(state.logs || "Loading…")}
+ `; +} + +function renderProxy() { + const connected = state.status.connected + ? `Connected to ${state.status.baseUrl}` + : "Not connected. The quota page is showing the sample board."; + return ` +

Proxy

+

${escapeHtml(connected)}

+
+

CLIProxy management API

+

CLIProxyAPI v8 listens on port 8317. The management key is management.secret-key in config.yaml. Run cli-proxy-api --claude-login once for each Claude subscription. This page stores that key in data/settings.json and does not print it back. It talks to /v8/management and falls back to /v0/management if v8 is not there.

+
+ + + + +
+ + +
+
+
+
+

What already exists

+

The stock page is Cli-Proxy-API-Management-Center, which ships inside CLIProxy as /management.html. Theo said his copy is an extensive private fork of VibeProxy and will not match a fresh install. Public quota tools (Quotio, Infinitus, CLIProxy Quota Tray, quota-reset-router) cover pieces of this. None of them is that fork. This console is the local board for the windows he was pointing at: 7-day, 5-hour, the dimmed extra bucket, and soonest-reset routing.

+
+ `; +} + +function bind() { + document.querySelectorAll("[data-filter]").forEach((button) => { + button.addEventListener("click", () => { + state.filter = button.dataset.filter; + render(); + }); + }); + document.getElementById("refresh")?.addEventListener("click", loadBoard); + document.getElementById("reload-logs")?.addEventListener("click", loadLogs); + document.getElementById("soonest")?.addEventListener("click", () => saveRouting("soonest")); + document.getElementById("even")?.addEventListener("click", () => saveRouting("even")); + document.getElementById("ws-on")?.addEventListener("click", () => saveRouting("sockets", true)); + document.getElementById("ws-off")?.addEventListener("click", () => saveRouting("sockets", false)); + document.getElementById("client-key")?.addEventListener("change", (event) => { + state.clientKey = event.target.value; + localStorage.setItem("cliproxy-console-client-key", state.clientKey); + render(); + }); + document.getElementById("connect")?.addEventListener("submit", async (event) => { + event.preventDefault(); + state.busy = true; + state.error = ""; + render(); + const response = await fetch("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: document.getElementById("base").value, + managementKey: document.getElementById("mgmt").value, + }), + }); + const body = await response.json(); + state.busy = false; + if (!response.ok) { + state.error = body.error || "Connect failed"; + state.page = "proxy"; + render(); + return; + } + state.status = body; + state.page = "quota"; + await loadBoard(); + }); + document.getElementById("disconnect")?.addEventListener("click", async () => { + await fetch("/api/settings", { method: "DELETE" }); + state.status = { connected: false }; + state.board = sampleBoard(); + render(); + }); +} + +async function saveRouting(mode, websockets) { + state.busy = true; + state.routingNote = ""; + render(); + const response = await fetch("/api/routing", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode, websockets }), + }); + const body = await response.json(); + state.busy = false; + if (!response.ok) { + state.routingNote = body.error || "Could not update routing"; + } else { + const failed = (body.priorities || []).filter((item) => !item.ok); + const socketNote = body.websockets?.length + ? ` Websocket flag updated on ${body.websockets.length} Codex account(s).` + : ""; + state.routingNote = body.warning + || (mode === "sockets" + ? `Websocket transport ${websockets ? "enabled" : "disabled"} for Codex auth files.${socketNote}` + : `Strategy is now ${body.strategy}, session affinity is on.${failed.length ? ` ${failed.length} priority update(s) failed.` : ""}`); + await loadBoard(); + return; + } + render(); +} + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (char) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[char])); +} + +function escapeAttr(value) { + return escapeHtml(value); +} + +await loadStatus(); +await loadBoard(); diff --git a/coding-setup/cliproxy-console/public/index.html b/coding-setup/cliproxy-console/public/index.html new file mode 100644 index 0000000..630601e --- /dev/null +++ b/coding-setup/cliproxy-console/public/index.html @@ -0,0 +1,30 @@ + + + + + + CLIProxy console + + + +
+ +
+
+ + + diff --git a/coding-setup/cliproxy-console/public/styles.css b/coding-setup/cliproxy-console/public/styles.css new file mode 100644 index 0000000..986b177 --- /dev/null +++ b/coding-setup/cliproxy-console/public/styles.css @@ -0,0 +1,238 @@ +:root { + color-scheme: dark; + --bg: #0e100e; + --panel: #151815; + --panel-2: #1c201c; + --line: #2c332c; + --text: #e8eee6; + --muted: #8e998c; + --green: #3ecf8e; + --green-dim: #1f7a4e; + --yellow: #e3c15a; + --danger: #e07a6a; + --shadow: 0 18px 40px rgba(0, 0, 0, 0.28); + font-family: "Segoe UI", ui-sans-serif, sans-serif; +} + +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--text); +} +button, input, select { font: inherit; color: inherit; } +button { cursor: pointer; } + +.app { + display: grid; + grid-template-columns: 232px 1fr; + min-height: 100vh; +} + +.nav { + border-right: 1px solid var(--line); + padding: 22px 14px; + background: #101310; + position: sticky; + top: 0; + height: 100vh; +} +.brand { + display: flex; + gap: 10px; + align-items: center; + margin: 0 8px 22px; +} +.brand strong { display: block; font-size: 15px; } +.brand em { display: block; color: var(--muted); font-style: normal; font-size: 12px; } +.mark { + width: 34px; + height: 34px; + border-radius: 9px; + display: grid; + place-items: center; + background: #143524; + color: var(--green); + font-weight: 700; + letter-spacing: 0.04em; +} +.nav-btn { + width: 100%; + text-align: left; + background: transparent; + border: 0; + border-radius: 8px; + padding: 9px 12px; + color: var(--muted); +} +.nav-btn.active { + background: #1a241c; + color: var(--text); + box-shadow: inset 2px 0 0 var(--green); +} +.nav-note { + margin: 28px 10px 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +main { padding: 28px 32px 64px; max-width: 1180px; } +h1 { font-size: 28px; font-weight: 650; margin: 0 0 6px; } +.lede { color: var(--muted); margin: 0 0 18px; max-width: 68ch; line-height: 1.5; } + +.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.toolbar { margin: 8px 0 18px; } +.chips { display: flex; gap: 8px; flex-wrap: wrap; } +.chip, .btn { + border: 1px solid var(--line); + background: var(--panel); + border-radius: 999px; + padding: 6px 12px; +} +.chip.active { border-color: var(--green-dim); color: var(--green); } +.btn.primary { background: #173526; border-color: #246846; color: var(--green); } +.btn:disabled { opacity: 0.5; cursor: wait; } + +.cards { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 22px; +} +.card, .account, .panel, .logbox, pre.snippet { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 14px; +} +.card { padding: 14px 16px 16px; } +.card .k { color: var(--muted); font-size: 13px; text-transform: capitalize; } +.card .n { font-size: 28px; margin: 8px 0; } +.card .sub { color: var(--muted); font-size: 12px; } +.card.bad { border-color: #6d3530; background: #2a1614; } +.card.bad .n { color: #f0c2ba; font-size: 20px; } +.card.bad .sub { color: #f0c2ba; } + +.bar { + height: 6px; + background: #2a312a; + border-radius: 99px; + overflow: hidden; +} +.bar i { display: block; height: 100%; border-radius: 99px; background: var(--green); } +.bar i.mid { background: var(--yellow); } +.bar i.low { background: var(--danger); } +.bar.dim i { background: #667066; } + +.source-note { + margin: -4px 0 10px; + color: var(--muted); + font-size: 13px; + line-height: 1.45; + max-width: 78ch; +} +.group-title { + margin: 22px 0 10px; + font-size: 14px; + color: var(--muted); + text-transform: capitalize; +} +.account { + display: grid; + grid-template-columns: 260px 1fr; + gap: 18px; + padding: 14px 16px; + margin-bottom: 10px; +} +.account.next { border-color: #246846; } +.file { + font-family: ui-monospace, "Cascadia Code", monospace; + font-size: 13px; + word-break: break-all; +} +.meta { display: flex; gap: 8px; margin-top: 8px; align-items: center; } +.pill { + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--green); + border: 1px solid #246846; + border-radius: 99px; + padding: 2px 7px; +} +.pill.next { color: var(--yellow); border-color: #6d6230; } +.plan { color: var(--muted); font-size: 12px; } +.group-title .plan { text-transform: none; font-weight: 400; } +.windows { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 16px; + align-content: start; +} +.window .wh { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; } +.window .when { color: var(--muted); font-size: 12px; margin-top: 6px; } +.window.dim { opacity: 0.55; } +.usage-foot { + grid-column: 1 / -1; + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 2px; + padding-top: 8px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 13px; +} +.usage-foot b { color: var(--text); font-weight: 550; } +.err { color: var(--danger); font-size: 12px; margin-top: 8px; } + +.banner { + border: 1px solid #6d6230; + background: #231f12; + color: #f0e2a8; + border-radius: 12px; + padding: 10px 12px; + margin-bottom: 16px; + line-height: 1.45; +} +.banner.bad { border-color: #6d3530; background: #2a1614; color: #f0c2ba; } + +.panel { padding: 16px 18px; margin-bottom: 14px; } +.panel h2 { margin: 0 0 8px; font-size: 16px; } +.panel p, .help { color: var(--muted); line-height: 1.5; } +label { display: block; font-size: 13px; color: var(--muted); margin: 12px 0 6px; } +input[type="text"], input[type="password"], input[type="url"] { + width: min(520px, 100%); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 10px; +} +.choice { display: flex; gap: 10px; margin: 8px 0 12px; } +.choice button { border-radius: 10px; } + +pre.snippet { + padding: 12px 14px; + overflow: auto; + font-family: ui-monospace, monospace; + font-size: 12.5px; + line-height: 1.5; +} +.logbox { + padding: 12px 14px; + min-height: 280px; + white-space: pre-wrap; + font-family: ui-monospace, monospace; + font-size: 12px; + color: #c5d0c3; +} + +.legend { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 18px; } +.legend div { font-size: 13px; line-height: 1.45; } +.legend strong { display: block; } + +@media (max-width: 980px) { + .app { grid-template-columns: 1fr; } + .nav { height: auto; position: static; } + .cards, .account, .windows, .legend { grid-template-columns: 1fr; } +} diff --git a/coding-setup/cliproxy-console/server.mjs b/coding-setup/cliproxy-console/server.mjs new file mode 100644 index 0000000..bd8d419 --- /dev/null +++ b/coding-setup/cliproxy-console/server.mjs @@ -0,0 +1,619 @@ +import { createServer } from "node:http"; +import { readFile, mkdir, writeFile, chmod } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { DatabaseSync } from "node:sqlite"; +import { + decorateAccount, + filesFromList, + orgTypeFromProfile, + planFromUsage, + poolLabel, + poolProblems, + pooledWeekly, + profileRequest, + parseUsageBody, + patchRoutingYaml, + prioritiesFor, + providerOf, + rankForSoonestReset, + tierFromProfile, + tierProblem, + usageReport, + usageRequest, + windowsFromCategories, + windowsFromUsage, +} from "./lib/normalize.mjs"; +import { + summarizeCodex, + summarizeCursorEdits, + summarizeCursorPlan, + summarizeOpenCode, +} from "./lib/consumption.mjs"; + +const API = { + v8: { + list: "/v8/management/credentials", + apiCall: "/v8/management/requests/api-call", + quotaFetch: "/v8/management/credentials/quota/fetch", + config: "/v8/management/config.yaml", + fields: "/v8/management/credentials/fields", + logs: "/v8/management/logs", + }, + v0: { + list: "/v0/management/auth-files", + apiCall: "/v0/management/api-call", + quotaFetch: "", + config: "/v0/management/config.yaml", + fields: "/v0/management/auth-files/fields", + logs: "/v0/management/logs", + }, +}; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const publicDir = path.join(root, "public"); +const dataDir = path.join(root, "data"); +const settingsPath = path.join(dataDir, "settings.json"); +const port = Number(process.env.PORT || 8787); + +const MIME = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".svg": "image/svg+xml", +}; + +async function loadSettings() { + try { + const raw = await readFile(settingsPath, "utf8"); + const parsed = JSON.parse(raw); + if (!parsed.baseUrl || !parsed.managementKey) return null; + return { + baseUrl: String(parsed.baseUrl).replace(/\/$/, ""), + managementKey: String(parsed.managementKey), + clientApiKey: parsed.clientApiKey ? String(parsed.clientApiKey) : "", + }; + } catch { + return null; + } +} + +async function saveSettings(settings) { + await mkdir(dataDir, { recursive: true }); + await writeFile(settingsPath, JSON.stringify(settings, null, 2)); + await chmod(settingsPath, 0o600); +} + +function send(res, status, body, type = "application/json; charset=utf-8") { + const payload = typeof body === "string" ? body : JSON.stringify(body); + res.writeHead(status, { + "Content-Type": type, + "Cache-Control": "no-store", + }); + res.end(payload); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + if (!text) return resolve({}); + try { + resolve(JSON.parse(text)); + } catch (error) { + reject(error); + } + }); + req.on("error", reject); + }); +} + +async function mgmt(settings, apiPath, { method = "GET", body, contentType } = {}) { + const headers = { Authorization: `Bearer ${settings.managementKey}` }; + let payload; + if (body != null) { + if (contentType === "application/yaml") { + headers["Content-Type"] = "application/yaml"; + payload = body; + } else { + headers["Content-Type"] = "application/json"; + payload = JSON.stringify(body); + } + } + const response = await fetch(`${settings.baseUrl}${apiPath}`, { + method, + headers, + body: payload, + signal: AbortSignal.timeout(12000), + }); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + if (!response.ok) { + const message = json?.message || json?.error || text.slice(0, 280) || response.statusText; + const error = new Error(message); + error.status = response.status; + throw error; + } + return { text, json }; +} + +async function mgmtFirst(settings, paths, options) { + let lastError = null; + for (const apiPath of paths.filter(Boolean)) { + try { + return await mgmt(settings, apiPath, options); + } catch (error) { + lastError = error; + if (error.status !== 404 && error.status !== 405) throw error; + } + } + if (lastError) throw lastError; + throw new Error("No management path configured"); +} + +async function detectApi(settings) { + try { + const listed = await mgmt(settings, API.v8.list); + return { version: "v8", paths: API.v8, listed }; + } catch (error) { + if (error.status !== 404 && error.status !== 405) throw error; + } + const listed = await mgmt(settings, API.v0.list); + return { version: "v0", paths: API.v0, listed }; +} + +function reportFromBody(provider, body) { + const report = usageReport(provider, body); + const windows = report.categories.length + ? windowsFromCategories(report.categories) + : windowsFromUsage(provider, body); + return { windows, categories: report.categories, extraUsage: report.extra, quotaError: "", plan: planFromUsage(provider, body) }; +} + +async function windowsFor(settings, paths, file) { + const provider = providerOf(file); + const authIndex = file.auth_index || file.authIndex; + const empty = { windows: [], categories: [], extraUsage: null, quotaError: "" }; + if (!authIndex || file.disabled) return empty; + + if (paths.quotaFetch) { + try { + const fetched = await mgmt(settings, paths.quotaFetch, { + method: "POST", + body: { auth_index: authIndex, provider }, + }); + const body = fetched.json?.body ? parseUsageBody(fetched.json.body) : fetched.json; + const report = reportFromBody(provider, body); + if (report.windows.length || report.categories.length) return report; + } catch (error) { + if (error.status !== 404 && error.status !== 405 && error.status !== 501) { + return { ...empty, quotaError: error.message }; + } + } + } + + const request = usageRequest(provider); + if (!request) return empty; + try { + const called = await mgmt(settings, paths.apiCall, { + method: "POST", + body: { auth_index: authIndex, ...request }, + }); + const upstream = called.json || {}; + if (upstream.status_code && upstream.status_code >= 400) { + return { ...empty, quotaError: `usage endpoint returned ${upstream.status_code}` }; + } + return reportFromBody(provider, parseUsageBody(upstream.body)); + } catch (error) { + return { ...empty, quotaError: error.message }; + } +} + +async function tierFor(settings, paths, file) { + const request = profileRequest(providerOf(file)); + const authIndex = file.auth_index || file.authIndex; + if (!request || !authIndex || file.disabled) return { tier: "", orgType: "", error: "" }; + try { + const called = await mgmt(settings, paths.apiCall, { + method: "POST", + body: { auth_index: authIndex, ...request }, + }); + const upstream = called.json || {}; + if (upstream.status_code && upstream.status_code >= 400) { + return { tier: "", orgType: "", error: `profile call returned ${upstream.status_code}` }; + } + const profile = parseUsageBody(upstream.body); + const tier = tierFromProfile(profile); + return { tier, orgType: orgTypeFromProfile(profile), error: tierProblem(tier) }; + } catch (error) { + return { tier: "", orgType: "", error: `profile call failed (${error.message})` }; + } +} + +async function localAccountMeta(file) { + const filePath = String(file?.path || ""); + const authDir = path.join(os.homedir(), ".cli-proxy-api") + path.sep; + if (!filePath.startsWith(authDir)) return {}; + try { + const parsed = JSON.parse(await readFile(filePath, "utf8")); + return { + organization: typeof parsed.organization_name === "string" ? parsed.organization_name : "", + plan: typeof parsed.subscription_type === "string" ? parsed.subscription_type : "", + apiKey: Boolean(parsed.api_key), + }; + } catch { + return {}; + } +} + +async function codexLoginType() { + try { + const auth = JSON.parse(await readFile(path.join(os.homedir(), ".codex/auth.json"), "utf8")); + if (auth.auth_mode === "apikey") return "API key"; + if (auth.auth_mode === "chatgpt") return "ChatGPT login"; + return auth.auth_mode ? String(auth.auth_mode) : ""; + } catch { + return ""; + } +} + +async function loadBoard(settings) { + const api = await detectApi(settings); + const files = filesFromList(api.listed.json); + const accounts = []; + for (const file of files) { + const usage = await windowsFor(settings, api.paths, file); + const meta = await localAccountMeta(file); + const profile = await tierFor(settings, api.paths, file); + const account = decorateAccount(file, usage.windows, { + ...meta, + plan: meta.plan || usage.plan, + tier: profile.tier, + orgType: profile.orgType, + tierError: profile.error, + categories: usage.categories, + extraUsage: usage.extraUsage, + }); + account.quotaError = usage.quotaError; + accounts.push(account); + } + return { source: "live", api: api.version, accounts }; +} + +async function listAccounts(settings) { + const api = await detectApi(settings); + return { + api: api.version, + accounts: filesFromList(api.listed.json).map((file) => decorateAccount(file, [])), + }; +} + +function publicSettings(settings) { + if (!settings) return { connected: false }; + return { + connected: true, + baseUrl: settings.baseUrl, + clientApiKeySet: Boolean(settings.clientApiKey), + }; +} + +async function applyRouting(settings, mode, websockets) { + const strategy = mode === "even" ? "round-robin" : "fill-first"; + const api = await detectApi(settings); + if (mode !== "sockets") { + const current = await mgmtFirst(settings, [api.paths.config, API.v0.config]); + const patched = patchRoutingYaml(current.text, { + strategy, + sessionAffinity: true, + }); + if (!patched.ok) { + const error = new Error( + "The routing block is inline YAML. Edit config.yaml by hand and set strategy plus session-affinity.", + ); + error.status = 422; + throw error; + } + await mgmtFirst(settings, [api.paths.config, API.v0.config], { + method: "PUT", + body: patched.yaml, + contentType: "application/yaml", + }); + } + + const board = mode === "sockets" ? await listAccounts(settings) : await loadBoard(settings); + const haveResets = board.accounts.some((account) => account.weeklyResetMs != null); + const priorities = mode === "soonest" && haveResets ? prioritiesFor(board.accounts) : []; + const priorityResults = []; + for (const item of priorities) { + try { + await mgmtFirst(settings, [api.paths.fields, API.v0.fields], { + method: "PATCH", + body: { name: item.name, priority: item.priority }, + }); + priorityResults.push({ name: item.name, priority: item.priority, ok: true }); + } catch (error) { + priorityResults.push({ name: item.name, priority: item.priority, ok: false, error: error.message }); + } + } + + const socketResults = []; + if (websockets != null) { + for (const account of board.accounts.filter((item) => item.provider === "codex")) { + try { + await mgmtFirst(settings, [api.paths.fields, API.v0.fields], { + method: "PATCH", + body: { name: account.name, websockets: Boolean(websockets) }, + }); + socketResults.push({ name: account.name, ok: true }); + } catch (error) { + socketResults.push({ name: account.name, ok: false, error: error.message }); + } + } + } + + return { + strategy: mode === "sockets" ? null : strategy, + sessionAffinity: mode !== "sockets", + priorities: priorityResults, + websockets: socketResults, + warning: mode === "soonest" && !haveResets + ? "Routing is fill-first, but quota windows were not readable, so account priority was left as it was." + : "", + }; +} + +async function handleApi(req, res, url) { + if (req.method === "GET" && url.pathname === "/api/status") { + return send(res, 200, publicSettings(await loadSettings())); + } + + if (req.method === "POST" && url.pathname === "/api/settings") { + const body = await readBody(req); + const baseUrl = String(body.baseUrl || "").trim().replace(/\/$/, ""); + const managementKey = String(body.managementKey || ""); + if (!/^https?:\/\//.test(baseUrl) || !managementKey) { + return send(res, 400, { error: "Need an http(s) proxy URL and a management key." }); + } + const settings = { + baseUrl, + managementKey, + clientApiKey: String(body.clientApiKey || ""), + }; + await detectApi(settings); + await saveSettings(settings); + return send(res, 200, publicSettings(settings)); + } + + if (req.method === "DELETE" && url.pathname === "/api/settings") { + await saveSettings({ baseUrl: "", managementKey: "", clientApiKey: "" }).catch(() => {}); + if (existsSync(settingsPath)) { + await writeFile(settingsPath, "{}\n"); + await chmod(settingsPath, 0o600); + } + return send(res, 200, { connected: false }); + } + + if (req.method === "GET" && url.pathname === "/api/board") { + const settings = await loadSettings(); + if (!settings?.managementKey) { + return send(res, 200, { source: "demo", accounts: [] }); + } + try { + const board = await loadBoard(settings); + const groups = groupBoard(board.accounts); + return send(res, 200, { ...board, groups, preferred: prioritiesFor(board.accounts)[0] || null }); + } catch (error) { + return send(res, error.status || 502, { error: error.message }); + } + } + + if (req.method === "POST" && url.pathname === "/api/routing") { + const settings = await loadSettings(); + if (!settings?.managementKey) { + return send(res, 409, { error: "Connect the proxy first." }); + } + const body = await readBody(req); + const mode = body.mode === "even" || body.mode === "sockets" ? body.mode : "soonest"; + try { + const result = await applyRouting(settings, mode, body.websockets); + return send(res, 200, result); + } catch (error) { + return send(res, error.status || 502, { error: error.message }); + } + } + + if (req.method === "GET" && url.pathname === "/api/consumption") { + try { + return send(res, 200, await collectConsumption()); + } catch (error) { + return send(res, 500, { error: error.message || "Could not read local usage" }); + } + } + + if (req.method === "GET" && url.pathname === "/api/logs") { + const settings = await loadSettings(); + if (!settings?.managementKey) return send(res, 200, { lines: [] }); + try { + const after = url.searchParams.get("after") || ""; + const query = after ? `?after=${encodeURIComponent(after)}` : ""; + const api = await detectApi(settings); + const logs = await mgmtFirst(settings, [`${api.paths.logs}${query}`, `${API.v0.logs}${query}`]); + return send(res, 200, logs.json || { lines: [] }); + } catch (error) { + return send(res, error.status || 502, { error: error.message }); + } + } + + return send(res, 404, { error: "not found" }); +} + +function readRows(dbPath, sql) { + if (!existsSync(dbPath)) return []; + const db = new DatabaseSync(dbPath, { readOnly: true }); + try { + return db.prepare(sql).all(); + } finally { + db.close(); + } +} + +function tryRows(dbPath, sql) { + try { + return { rows: readRows(dbPath, sql), error: "" }; + } catch (error) { + return { rows: [], error: error.message || "Could not read the local database" }; + } +} + +function cellText(value) { + if (typeof value === "string") return value; + if (value == null) return ""; + return Buffer.from(value).toString("utf8"); +} + +async function cursorPlan() { + const dbPath = path.join(os.homedir(), ".config/Cursor/User/globalStorage/state.vscdb"); + const rows = existsSync(dbPath) + ? readRows(dbPath, "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'") + : []; + const token = cellText(rows[0]?.value).trim(); + if (!token) return { plan: null, error: "No Cursor login on this machine." }; + const response = await fetch("https://api2.cursor.sh/auth/usage-summary", { + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + signal: AbortSignal.timeout(12000), + }); + if (!response.ok) return { plan: null, error: `Cursor usage returned ${response.status}` }; + return { plan: summarizeCursorPlan(await response.json()), error: "" }; +} + +async function collectConsumption() { + const home = os.homedir(); + const opencodeRead = tryRows( + path.join(home, ".local/share/opencode/opencode.db"), + `SELECT model, + COUNT(*) AS sessions, + COALESCE(SUM(tokens_input), 0) AS input, + COALESCE(SUM(tokens_output), 0) AS output, + COALESCE(SUM(tokens_reasoning), 0) AS reasoning, + COALESCE(SUM(tokens_cache_read), 0) AS cacheRead, + COALESCE(SUM(cost), 0) AS cost + FROM session_v2 + GROUP BY model`, + ); + const { opencode, bedrock } = summarizeOpenCode(opencodeRead.rows); + const codexRead = tryRows( + path.join(home, ".codex/state_5.sqlite"), + `SELECT model, + model_provider AS provider, + COUNT(*) AS sessions, + COALESCE(SUM(tokens_used), 0) AS tokens + FROM threads + GROUP BY model, model_provider`, + ); + const codex = summarizeCodex(codexRead.rows); + const codexType = await codexLoginType(); + const editRead = tryRows( + path.join(home, ".cursor/ai-tracking/ai-code-tracking.db"), + "SELECT model, COUNT(*) AS edits FROM ai_code_hashes GROUP BY model", + ); + const edits = summarizeCursorEdits(editRead.rows); + let cursor = { plan: null, error: "" }; + try { + cursor = await cursorPlan(); + } catch (error) { + cursor = { plan: null, error: error.message || "Cursor usage could not be read" }; + } + return { + groups: [ + { + id: "codex", + title: "Codex", + source: "Local Codex threads", + accountType: codexType, + note: codexRead.error || (codexType === "API key" + ? "Token totals stored on Codex threads. This login is an API key, so there is no 5-hour or weekly subscription window to read." + : "Token totals stored on Codex threads."), + rows: codex, + }, + { + id: "opencode", + title: "OpenCode", + accountType: opencode.length ? `providers: ${[...new Set(opencode.map((row) => row.detail))].join(", ")}` : "", + source: "OpenCode session history", + note: opencodeRead.error || "Input, output, and cache-read tokens OpenCode recorded on finished sessions.", + rows: opencode, + }, + { + id: "bedrock", + title: "Bedrock", + accountType: bedrock.length ? "AWS pay per use" : "", + source: "OpenCode sessions on amazon-bedrock", + note: bedrock.some((row) => row.input + row.output > 0) + ? "Tokens OpenCode recorded for Amazon Bedrock models." + : "OpenCode started Bedrock sessions, but those requests stored no tokens. Bedrock rejected them before a completion was counted.", + rows: bedrock, + }, + { + id: "cursor", + title: "Cursor", + accountType: cursor.plan?.membership ? cursor.plan.membership.charAt(0).toUpperCase() + cursor.plan.membership.slice(1) : "", + source: "Cursor account, this billing cycle", + note: cursor.error || "Included allowance is the purchased monthly amount. Auto and API percents are Cursor's own meters. Editor edits are local code-tracking events, not tokens.", + plan: cursor.plan, + edits, + }, + ], + }; +} + +function groupBoard(accounts) { + const groups = new Map(); + for (const account of accounts) { + if (!groups.has(account.provider)) groups.set(account.provider, []); + groups.get(account.provider).push(account); + } + return [...groups.entries()].map(([provider, items]) => ({ + provider, + pooledWeekly: pooledWeekly(items), + poolLabel: poolLabel(items), + problems: poolProblems(items), + accounts: rankForSoonestReset(items), + })); +} + +async function handleStatic(req, res, url) { + const requested = url.pathname === "/" ? "/index.html" : url.pathname; + const filePath = path.normalize(path.join(publicDir, requested)); + if (!filePath.startsWith(publicDir)) return send(res, 403, { error: "forbidden" }); + try { + const data = await readFile(filePath); + const ext = path.extname(filePath); + res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" }); + res.end(data); + } catch { + send(res, 404, "Not found", "text/plain; charset=utf-8"); + } +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); + try { + if (url.pathname.startsWith("/api/")) await handleApi(req, res, url); + else await handleStatic(req, res, url); + } catch (error) { + send(res, 500, { error: error.message || "server error" }); + } +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`cliproxy console http://127.0.0.1:${port}`); +}); diff --git a/coding-setup/config/claude-cliproxy.environment.conf b/coding-setup/config/claude-cliproxy.environment.conf new file mode 100644 index 0000000..45865d9 --- /dev/null +++ b/coding-setup/config/claude-cliproxy.environment.conf @@ -0,0 +1,4 @@ +# T3 reads CLAUDE_CONFIG_DIR for session history. providers.claudeAgent.homePath +# is not consulted, and a self-update re-execs the binary with the session +# environment, so this has to be set for the whole login. +CLAUDE_CONFIG_DIR=@CLAUDE_HOME@ diff --git a/coding-setup/config/claude-cliproxy.settings.example.json b/coding-setup/config/claude-cliproxy.settings.example.json new file mode 100644 index 0000000..2dd9cec --- /dev/null +++ b/coding-setup/config/claude-cliproxy.settings.example.json @@ -0,0 +1,8 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317", + "ANTHROPIC_AUTH_TOKEN": "REPLACE_WITH_CLIENT_API_KEY", + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1", + "CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION": "1000" + } +} diff --git a/coding-setup/config/cli-proxy-api.config.yaml b/coding-setup/config/cli-proxy-api.config.yaml new file mode 100644 index 0000000..9381391 --- /dev/null +++ b/coding-setup/config/cli-proxy-api.config.yaml @@ -0,0 +1,36 @@ +# Fresh-install config for CLIProxyAPI 8.x. +# Copy to ~/.config/cli-proxy-api/config.yaml and replace both placeholders +# before the first start. CLIProxy bcrypt-hashes management.secret-key on +# startup and rewrites this file. Keep the plaintext somewhere private +# (console settings and T3). Do not commit the filled-in file. +# +# The laptop this was taken from still has a legacy top-level `request-retry`. +# A new install should use the v8 spelling under `routing.retry`, which is +# what a management-API write expects. + +config-version: 8 +server: + host: "127.0.0.1" + port: 8317 +management: + allow-remote: false + secret-key: "REPLACE_WITH_MANAGEMENT_KEY" + disable-control-panel: false +access: + api-keys: + - "REPLACE_WITH_CLIENT_API_KEY" +routing: + # round-robin spreads new sessions. fill-first plus per-account priority + # burns the account whose weekly window resets soonest. The console's + # Quota page can switch this later. Session affinity stays on either way, + # so a thread that already started keeps its account until that account + # returns 429 (or another retryable failure) and request-retry moves it. + strategy: "round-robin" + session-affinity: true + session-affinity-ttl: "1h" + session-affinity-subagents: true + retry: + request-retry: 3 + max-retry-credentials: 0 +oauth: + auth-dir: "~/.cli-proxy-api" diff --git a/coding-setup/config/cli-proxy-api.service b/coding-setup/config/cli-proxy-api.service new file mode 100644 index 0000000..a1547be --- /dev/null +++ b/coding-setup/config/cli-proxy-api.service @@ -0,0 +1,11 @@ +[Unit] +Description=CLIProxyAPI local account router +After=network-online.target + +[Service] +ExecStart=%h/.local/bin/cli-proxy-api --config %h/.config/cli-proxy-api/config.yaml +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target diff --git a/coding-setup/config/cliproxy-console.service b/coding-setup/config/cliproxy-console.service new file mode 100644 index 0000000..3193d84 --- /dev/null +++ b/coding-setup/config/cliproxy-console.service @@ -0,0 +1,16 @@ +[Unit] +Description=Local CLIProxy quota console +After=cli-proxy-api.service + +[Service] +# install.sh replaces @NODE@ with the absolute path of a Node that can import +# node:sqlite. systemd user services do not see nvm, so a bare `node` is not an option. +WorkingDirectory=%h/.local/share/cliproxy-console +ExecStartPre=@NODE@ -e "import('node:sqlite')" +ExecStart=@NODE@ server.mjs +Environment=PORT=8787 +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target diff --git a/coding-setup/config/t3-desktop-settings.snippet.json b/coding-setup/config/t3-desktop-settings.snippet.json new file mode 100644 index 0000000..0f6a696 --- /dev/null +++ b/coding-setup/config/t3-desktop-settings.snippet.json @@ -0,0 +1,5 @@ +{ + "serverExposureMode": "local-only", + "tailscaleServeEnabled": true, + "tailscaleServePort": 443 +} diff --git a/coding-setup/config/t3-settings.snippet.json b/coding-setup/config/t3-settings.snippet.json new file mode 100644 index 0000000..3cdda4b --- /dev/null +++ b/coding-setup/config/t3-settings.snippet.json @@ -0,0 +1,21 @@ +{ + "defaultThreadEnvMode": "worktree", + "sidebarAutoSettleAfterDays": 14, + "enableDeviceSupport": true, + "continueThreadsAfterServerUpdate": true, + "autoResumeLimitedThreads": true, + "providers": { + "claudeAgent": { + "homePath": "REPLACE_WITH_HOME/.claude-cliproxy" + } + }, + "usageLimitSources": { + "cliproxy-local": { + "kind": "cliproxy", + "label": "Local CLIProxy", + "url": "http://127.0.0.1:8317", + "managementKey": "REPLACE_WITH_MANAGEMENT_KEY", + "enabled": true + } + } +} diff --git a/coding-setup/config/t3code-autostart.desktop b/coding-setup/config/t3code-autostart.desktop new file mode 100644 index 0000000..60eafd4 --- /dev/null +++ b/coding-setup/config/t3code-autostart.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=T3 Code (Nightly) +Exec="/opt/T3 Code (Nightly)/t3code" +Icon=t3code +Terminal=false +X-GNOME-Autostart-enabled=true +X-GNOME-Autostart-Delay=10 diff --git a/coding-setup/install.sh b/coding-setup/install.sh new file mode 100755 index 0000000..3845f07 --- /dev/null +++ b/coding-setup/install.sh @@ -0,0 +1,399 @@ +#!/usr/bin/env bash +# Builds the agent workstation described in README.md. Idempotent: re-running a +# phase converges on the same state and never regenerates existing secrets. +# +# ./install.sh every phase, in order +# ./install.sh console t3 only the named phases +# ./install.sh login claude OAuth login for one subscription (interactive) +# +# Phases: node agents proxy claude console t3 autostart tailscale check +# Opt-in (not run by default): power +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +CONFIG_DIR="$HERE/config" + +BIN="$HOME/.local/bin/cli-proxy-api" +PROXY_CONFIG_DIR="$HOME/.config/cli-proxy-api" +PROXY_CONFIG="$PROXY_CONFIG_DIR/config.yaml" +KEYS_FILE="$PROXY_CONFIG_DIR/keys.env" +UNIT_DIR="$HOME/.config/systemd/user" +CLAUDE_HOME="$HOME/.claude-cliproxy" +CONSOLE_LINK="$HOME/.local/share/cliproxy-console" +T3_APP="/opt/T3 Code (Nightly)/t3code" +T3_USERDATA="$HOME/.t3/userdata" +NODE="" + +log() { printf '\n==> %s\n' "$*"; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" -ne 0 ] || die "run as your own user; the script calls sudo where it needs it" +[ -f /etc/os-release ] && . /etc/os-release +[ "${ID:-}" = "ubuntu" ] || die "Ubuntu only (found ${ID:-unknown})" + +for tool in curl python3 openssl sha256sum tar sed; do + command -v "$tool" >/dev/null || die "$tool is missing (the parent 1-BasicSetUp.sh installs the basics)" +done + +node_ok() { "$1" -e "import('node:sqlite')" >/dev/null 2>&1; } + +find_node() { + local candidate + for candidate in "${CLIPROXY_NODE:-}" /usr/bin/node "$(command -v node || true)"; do + if [ -n "$candidate" ] && [ -x "$candidate" ] && node_ok "$candidate"; then + NODE="$candidate" + return 0 + fi + done + return 1 +} + +phase_node() { + log "Node with node:sqlite" + if ! find_node; then + curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - + sudo apt-get install -y nodejs + find_node || die "no Node on this machine can import node:sqlite; set CLIPROXY_NODE to one that can" + fi + echo "using $NODE ($("$NODE" -v))" +} + +need_node() { [ -n "$NODE" ] || find_node || phase_node; } + +load_keys() { + [ -f "$KEYS_FILE" ] || die "$KEYS_FILE is missing. Run the proxy phase on a new machine. On a machine built before install.sh existed, create it with MANAGEMENT_KEY='...' and CLIENT_API_KEY='...' (mode 0600)." + set -a + . "$KEYS_FILE" + set +a +} + +systemd_install_unit() { + local name="$1" node_path="${2:-}" + mkdir -p "$UNIT_DIR" + if [ -n "$node_path" ]; then + sed "s|@NODE@|$node_path|g" "$CONFIG_DIR/$name" >"$UNIT_DIR/$name" + else + cp "$CONFIG_DIR/$name" "$UNIT_DIR/$name" + fi + systemctl --user daemon-reload +} + +# Prints " " for the newest CLIProxyAPI release of this +# architecture. The sha256 is the digest GitHub computed for the uploaded asset. +latest_cliproxy() { + python3 - "$1" <<'PY' +import json, sys, urllib.request + +arch = sys.argv[1] +with urllib.request.urlopen("https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest") as response: + release = json.load(response) +version = release["tag_name"].lstrip("v") +name = f"CLIProxyAPI_{version}_linux_{arch}.tar.gz" +for asset in release["assets"]: + if asset["name"] == name: + digest = (asset.get("digest") or "").removeprefix("sha256:") + if not digest: + sys.exit(f"GitHub published no sha256 for {name}") + print(version, asset["browser_download_url"], digest) + sys.exit(0) +sys.exit(f"no {name} in release {release['tag_name']}") +PY +} + +phase_proxy() { + log "CLIProxyAPI (newest release)" + local arch latest url sha installed found + case "$(uname -m)" in + x86_64) arch=amd64 ;; + aarch64 | arm64) arch=aarch64 ;; + *) die "unsupported architecture $(uname -m)" ;; + esac + found="$(latest_cliproxy "$arch")" || die "could not read the newest CLIProxyAPI release from GitHub" + read -r latest url sha <<<"$found" + installed="$("$BIN" --version 2>&1 | sed -n 's/^CLIProxyAPI Version: \([0-9.]*\).*/\1/p' | head -1 || true)" + if [ "$installed" != "$latest" ]; then + echo "installing CLIProxyAPI $latest (installed: ${installed:-none})" + local tmp archive="CLIProxyAPI_${latest}_linux_${arch}.tar.gz" + tmp="$(mktemp -d)" + curl -fL -o "$tmp/$archive" "$url" + echo "$sha $tmp/$archive" | sha256sum -c - + tar -xzf "$tmp/$archive" -C "$tmp" + mkdir -p "$(dirname "$BIN")" + install -m 0755 "$tmp/cli-proxy-api" "$BIN" + rm -r "$tmp" + else + echo "CLIProxyAPI $installed is already the newest release" + fi + + mkdir -p "$PROXY_CONFIG_DIR" + chmod 700 "$PROXY_CONFIG_DIR" + if [ ! -f "$KEYS_FILE" ]; then + [ ! -f "$PROXY_CONFIG" ] || die "$PROXY_CONFIG exists but $KEYS_FILE does not; refusing to mint keys that would not match it" + (umask 077 && printf "MANAGEMENT_KEY='%s'\nCLIENT_API_KEY='%s'\n" "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" >"$KEYS_FILE") + echo "generated $KEYS_FILE (mode 0600). Copy it to your password manager." + fi + load_keys + + if [ ! -f "$PROXY_CONFIG" ]; then + python3 - "$CONFIG_DIR/cli-proxy-api.config.yaml" "$PROXY_CONFIG" <<'PY' +import os, sys +src, dest = sys.argv[1:3] +text = open(src).read() +text = text.replace("REPLACE_WITH_MANAGEMENT_KEY", os.environ["MANAGEMENT_KEY"]) +text = text.replace("REPLACE_WITH_CLIENT_API_KEY", os.environ["CLIENT_API_KEY"]) +fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) +with os.fdopen(fd, "w") as handle: + handle.write(text) +PY + fi + + systemd_install_unit cli-proxy-api.service + systemctl --user enable cli-proxy-api.service + systemctl --user restart cli-proxy-api.service +} + +install_session_claude_home() { + mkdir -p "$HOME/.config/environment.d" + sed "s|@CLAUDE_HOME@|$CLAUDE_HOME|g" "$CONFIG_DIR/claude-cliproxy.environment.conf" \ + >"$HOME/.config/environment.d/claude-cliproxy.conf" + echo "wrote ~/.config/environment.d/claude-cliproxy.conf (takes effect on the next login)" +} + +phase_agents() { + log "Agent CLIs: Claude Code, Cursor Agent, OpenCode 2" + export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" + command -v claude >/dev/null || curl -fsSL https://claude.ai/install.sh | bash + command -v cursor-agent >/dev/null || curl -fsS https://cursor.com/install | bash + if ! command -v opencode2 >/dev/null; then + command -v unzip >/dev/null || sudo apt-get install -y unzip + command -v bun >/dev/null || curl -fsSL https://bun.sh/install | bash + bun add -g @opencode/cli + fi + local tool + for tool in claude cursor-agent opencode2; do + command -v "$tool" >/dev/null || die "$tool is not on PATH after its installer ran" + done +} + +phase_claude() { + log "Isolated Claude home $CLAUDE_HOME" + install_session_claude_home + [ ! -f "$KEYS_FILE" ] || load_keys + python3 - "$CONFIG_DIR/claude-cliproxy.settings.example.json" <<'PY' +import json, os, sys +from pathlib import Path + +example = json.loads(Path(sys.argv[1]).read_text()) +dest = Path.home() / ".claude-cliproxy" / "settings.json" +dest.parent.mkdir(mode=0o700, parents=True, exist_ok=True) +current = json.loads(dest.read_text()) if dest.exists() else {} +env = current.setdefault("env", {}) +for key, value in example["env"].items(): + if key == "ANTHROPIC_AUTH_TOKEN": + token = os.environ.get("CLIENT_API_KEY") or env.get(key) + if not token or token.startswith("REPLACE_"): + sys.exit("no client API key: run the proxy phase first") + env[key] = token + else: + env[key] = value +dest.write_text(json.dumps(current, indent=2) + "\n") +dest.chmod(0o600) +PY + local item + for item in CLAUDE.md skills plugins hooks; do + if [ -e "$HOME/.claude/$item" ] && { [ ! -e "$CLAUDE_HOME/$item" ] || [ -L "$CLAUDE_HOME/$item" ]; }; then + ln -sfn "$HOME/.claude/$item" "$CLAUDE_HOME/$item" + fi + done +} + +phase_console() { + log "Quota console" + need_node + (cd "$HERE/cliproxy-console" && "$NODE" --test >/dev/null) || die "console tests failed" + if [ -e "$CONSOLE_LINK" ] && [ ! -L "$CONSOLE_LINK" ]; then + die "$CONSOLE_LINK exists and is not a symlink; move it away first" + fi + mkdir -p "$(dirname "$CONSOLE_LINK")" + ln -sfn "$HERE/cliproxy-console" "$CONSOLE_LINK" + + local data="$HERE/cliproxy-console/data" + if [ ! -f "$data/settings.json" ] && [ -f "$KEYS_FILE" ]; then + load_keys + mkdir -p "$data" + chmod 700 "$data" + python3 - "$data/settings.json" <<'PY' +import json, os, sys +dest = sys.argv[1] +body = { + "baseUrl": "http://127.0.0.1:8317", + "managementKey": os.environ["MANAGEMENT_KEY"], + "clientApiKey": os.environ["CLIENT_API_KEY"], +} +fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) +with os.fdopen(fd, "w") as handle: + json.dump(body, handle, indent=2) +PY + fi + + systemd_install_unit cliproxy-console.service "$NODE" + systemctl --user enable cliproxy-console.service + systemctl --user restart cliproxy-console.service +} + +t3_running() { pgrep -x t3code >/dev/null 2>&1; } + +merge_json() { + # merge_json [KEY=VALUE ...]; nested dicts merge, anything else is replaced. + python3 - "$@" <<'PY' +import json, os, sys +from pathlib import Path + +dest, snippet, *overrides = sys.argv[1:] +path = Path(dest) +current = json.loads(path.read_text()) if path.exists() else {} +incoming = json.loads(Path(snippet).read_text()) + +def merge(into, src): + for key, value in src.items(): + if isinstance(value, dict) and isinstance(into.get(key), dict): + merge(into[key], value) + else: + into[key] = value + +merge(current, incoming) +for item in overrides: + dotted, value = item.split("=", 1) + if value.startswith("env:"): + value = os.environ[value[4:]] + node = current + *parents, leaf = dotted.split(".") + for part in parents: + node = node.setdefault(part, {}) + node[leaf] = value +path.parent.mkdir(parents=True, exist_ok=True) +path.write_text(json.dumps(current, indent=2) + "\n") +path.chmod(0o600) +PY +} + +# T3 Nightly ships only as a .deb attached to the GitHub releases page +# (https://github.com/pingdotgg/t3code/releases), next to preview builds that +# install to a different directory. Only the newest -nightly. asset is taken. Set T3_DEB to a .deb you already downloaded to skip this. +install_t3_deb() { + local deb="${T3_DEB:-}" tmp name url digest + if [ -z "$deb" ]; then + tmp="$(mktemp -d)" + found="$(python3 - "$(dpkg --print-architecture)" <<'PY' +import json, sys, urllib.request + +arch = sys.argv[1] +with urllib.request.urlopen("https://api.github.com/repos/pingdotgg/t3code/releases?per_page=30") as response: + releases = json.load(response) +for release in releases: + if release["draft"]: + continue + for asset in release["assets"]: + if "-nightly." in asset["name"] and asset["name"].endswith(f"-{arch}.deb"): + print(asset["name"], asset["browser_download_url"], (asset.get("digest") or "").removeprefix("sha256:")) + sys.exit(0) +sys.exit(f"no nightly {arch} .deb on the newest releases") +PY + )" || die "could not find a T3 Nightly .deb on GitHub; download one from the releases page and set T3_DEB" + read -r name url digest <<<"$found" + [ -n "$digest" ] || die "GitHub published no sha256 for $name; download it from the releases page and set T3_DEB" + curl -fL -o "$tmp/$name" "$url" + echo "$digest $tmp/$name" | sha256sum -c - + deb="$tmp/$name" + fi + sudo apt-get install -y "$deb" +} + +phase_t3() { + log "T3 Code Nightly" + if [ ! -x "$T3_APP" ]; then + install_t3_deb + fi + [ -x "$T3_APP" ] || die "T3 Code is not at $T3_APP after install" + install_session_claude_home + + load_keys + if t3_running; then + echo "T3 is running. settings.json is merged now, but desktop-settings.json is rewritten by the app on exit, so quit T3 and re-run this phase to apply the Serve and exposure settings." + else + merge_json "$T3_USERDATA/desktop-settings.json" "$CONFIG_DIR/t3-desktop-settings.snippet.json" + fi + merge_json "$T3_USERDATA/settings.json" "$CONFIG_DIR/t3-settings.snippet.json" \ + "providers.claudeAgent.homePath=$CLAUDE_HOME" \ + "usageLimitSources.cliproxy-local.managementKey=env:MANAGEMENT_KEY" +} + +phase_autostart() { + log "Start T3 at login" + mkdir -p "$HOME/.config/autostart" + cp "$CONFIG_DIR/t3code-autostart.desktop" "$HOME/.config/autostart/t3code.desktop" +} + +phase_tailscale() { + log "Tailscale" + command -v tailscale >/dev/null || curl -fsSL https://tailscale.com/install.sh | sh + local state + state="$(tailscale status --json 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("BackendState",""))' 2>/dev/null || true)" + if [ "$state" != "Running" ]; then + echo "Join this new device to your tailnet: approve it in the browser with the account your other devices use. The URL is a secret." + sudo tailscale up --operator="$USER" --hostname="$(hostname)" --accept-routes=false + fi + cat < Power" + sudo install -d /etc/systemd/logind.conf.d + printf '[Login]\nHandleLidSwitch=ignore\nHandleLidSwitchExternalPower=ignore\nHandleLidSwitchDocked=ignore\n' | + sudo tee /etc/systemd/logind.conf.d/10-coding-box.conf >/dev/null + sudo systemctl kill -s HUP systemd-logind +} + +phase_login() { + local provider="${1:-claude}" + case "$provider" in + claude | codex) ;; + *) die "login takes claude or codex" ;; + esac + "$BIN" -config "$PROXY_CONFIG" "-$provider-login" +} + +phase_check() { "$HERE/check.sh"; } + +main() { + local phases=("$@") + [ "${#phases[@]}" -gt 0 ] || phases=(node agents proxy claude console t3 autostart tailscale check) + if [ "${phases[0]}" = "login" ]; then + phase_login "${phases[1]:-claude}" + return + fi + local phase + for phase in "${phases[@]}"; do + case "$phase" in + node | agents | proxy | claude | console | t3 | autostart | tailscale | power | check) "phase_$phase" ;; + *) die "unknown phase '$phase'" ;; + esac + done + cat <<'EOF' + +Next, once per subscription (browser on this machine): + ./install.sh login claude +Then open T3 from the application menu, or log out and in to see it start on its own. +EOF +} + +main "$@" From ab0a3d7256ee4bab224ceca2bda09abf49236ee2 Mon Sep 17 00:00:00 2001 From: RS Nikhil Krishna Date: Thu, 8 Oct 2026 11:36:35 -0400 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20Keep=20install.sh=20going=20?= =?UTF-8?q?past=20a=20failing=20check,=20fix=20pipewire=20package=20and=20?= =?UTF-8?q?26.04=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.sh: a full run pauses on a terminal to offer the Claude login, always prints next steps even when check fails, and exits with check's status - 2-GenSoftware.sh: pick pipewire-bin or pipewire-utils, whichever the release has (pipewire-utils does not exist on 24.04, which is what failed CI on main) - CI: the 26.04 job runs on ubuntu-26.04; ubuntu-latest is still 24.04 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 2 +- 2-GenSoftware.sh | 5 +++- coding-setup/AGENTS.md | 2 +- coding-setup/README.md | 6 ++-- coding-setup/install.sh | 60 ++++++++++++++++++++++++++++++++-------- 5 files changed, 58 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 957831a..a55865c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: resolute-basic: name: Ubuntu Resolute Raccoon (26.04) - Basic Setup + GenSoftware - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/2-GenSoftware.sh b/2-GenSoftware.sh index c56d41f..12a15ef 100755 --- a/2-GenSoftware.sh +++ b/2-GenSoftware.sh @@ -205,7 +205,10 @@ fi if [ -x "$(command -v xhisper)" ]; then echo "xhisper already installed, skipping it" else - execute sudo apt-get install -y build-essential pipewire pipewire-utils jq ffmpeg wl-clipboard python3-gi gir1.2-gtk-3.0 bc + # The pipewire tools package is named pipewire-bin or pipewire-utils depending on the release. + pipewire_tools=pipewire-bin + if apt-cache show pipewire-utils >/dev/null 2>&1; then pipewire_tools=pipewire-utils; fi + execute sudo apt-get install -y build-essential pipewire "$pipewire_tools" jq ffmpeg wl-clipboard python3-gi gir1.2-gtk-3.0 bc xhisper_src="$(mktemp -d)" # This branch is the fork actually in use. abszar/main does not carry the conda and X11 fixes. git clone --depth 1 --branch fix/conda-env-and-clipboard-detection https://github.com/rsnk96/xhisper-ubuntu-linux.git "$xhisper_src" diff --git a/coding-setup/AGENTS.md b/coding-setup/AGENTS.md index 5cc78d3..306dab0 100644 --- a/coding-setup/AGENTS.md +++ b/coding-setup/AGENTS.md @@ -7,7 +7,7 @@ This directory is the source of truth and can stand alone as its own repository. ## Do this - On a blank Ubuntu machine, run the parent `1-BasicSetUp.sh` and `2-GenSoftware.sh` first, when those scripts are present. -- Run `./install.sh`, then `./install.sh login claude` once per subscription in a browser on that machine (`codex` only if the user asks), then `./check.sh`. Report the real output of `check.sh`; a WARN is not a pass. +- Run `./install.sh`. On a terminal it pauses to offer the Claude login (a browser on that machine, once per subscription). Where it could not, run `./install.sh login claude` per subscription (`codex` only if the user asks), then `./check.sh`. Report the real output of `check.sh`; a WARN is not a pass. - Prefer re-running one phase to editing a deployed file by hand. Phases are idempotent and merge into existing settings. - Keep the proxy on `127.0.0.1:8317` with `management.allow-remote: false`, and T3 on loopback (`serverExposureMode: "local-only"`). Tailscale Serve is published by T3 itself. - Leave `CLAUDE_CONFIG_DIR` pointed at `~/.claude-cliproxy` for the login session. `install.sh` writes `~/.config/environment.d/claude-cliproxy.conf`. It is read at login, and T3's history lookup uses it instead of `providers.claudeAgent.homePath`. diff --git a/coding-setup/README.md b/coding-setup/README.md index aa50352..899c9b9 100644 --- a/coding-setup/README.md +++ b/coding-setup/README.md @@ -20,9 +20,9 @@ Recorded from the working laptop on 2026-10-04: CLIProxyAPI 8.0.13 (new installs # 2. Everything in this directory cd coding-setup -./install.sh # asks for sudo where needed +./install.sh # asks for sudo where needed; at the end it offers the Claude login -# 3. One browser login per Claude subscription +# 3. One browser login per Claude subscription (skip if install.sh already did it) ./install.sh login claude # repeat per account ./install.sh login codex # optional @@ -43,7 +43,7 @@ cd coding-setup | `autostart` | Installs `~/.config/autostart/t3code.desktop`. | | `tailscale` | Installs Tailscale and joins your tailnet with `--operator="$USER" --accept-routes=false`, then reminds you to delete the older devices in the admin console. | | `power` | Opt-in. Never suspend on AC, ignore the lid switch. For a box that stays closed on a desk. | -| `check` | Runs `check.sh`. | +| `check` | Runs `check.sh`. A failing check does not stop the run: the next steps always print and the script exits with the check's status. | T3 Code has to come from the releases page. Nightly builds are prereleases there, so the "Latest" badge points at an older stable build; take the newest `T3-Code--nightly..-.deb`. Do not use the t3.codes install script. To install by hand, download that `.deb` and run `sudo apt install ./T3-Code-*.deb`, then `./install.sh t3` for the settings. diff --git a/coding-setup/install.sh b/coding-setup/install.sh index 3845f07..3a5d75d 100755 --- a/coding-setup/install.sh +++ b/coding-setup/install.sh @@ -374,26 +374,64 @@ phase_login() { phase_check() { "$HERE/check.sh"; } +claude_logins() { find "$HOME/.cli-proxy-api" -maxdepth 1 -name 'claude-*.json' 2>/dev/null | wc -l; } + +# Waits for the browser login to finish before the run goes on. Answering no, +# or running without a terminal, skips it and the next steps say what is left. +offer_claude_login() { + if ! [ -t 0 ] || ! [ -t 1 ]; then + echo "No terminal here, so the Claude login prompt is skipped." + return 0 + fi + local answer + while :; do + if [ "$(claude_logins)" -eq 0 ]; then + read -r -p "No Claude login yet. Log in now (a browser opens on this machine)? [Y/n] " answer || answer=n + answer="${answer:-y}" + else + read -r -p "$(claude_logins) Claude login(s) found. Add another subscription? [y/N] " answer || answer=n + answer="${answer:-n}" + fi + case "$answer" in [Yy]*) ;; *) return 0 ;; esac + phase_login claude || echo "The Claude login did not complete." + done +} + +print_next_steps() { + echo + if [ "$(claude_logins)" -eq 0 ]; then + echo "Next, once per subscription (browser on this machine):" + echo " ./install.sh login claude" + fi + echo "Then log out and back in once. That loads CLAUDE_CONFIG_DIR for T3, which starts on its own at login." + echo "Opening T3 before that breaks session history." +} + main() { - local phases=("$@") - [ "${#phases[@]}" -gt 0 ] || phases=(node agents proxy claude console t3 autostart tailscale check) + local phases=("$@") full_run=0 check_status=0 phase + if [ "${#phases[@]}" -eq 0 ]; then + phases=(node agents proxy claude console t3 autostart tailscale) + full_run=1 + fi if [ "${phases[0]}" = "login" ]; then phase_login "${phases[1]:-claude}" return fi - local phase for phase in "${phases[@]}"; do case "$phase" in - node | agents | proxy | claude | console | t3 | autostart | tailscale | power | check) "phase_$phase" ;; + node | agents | proxy | claude | console | t3 | autostart | tailscale | power) "phase_$phase" ;; + check) phase_check || check_status=$? ;; *) die "unknown phase '$phase'" ;; esac done - cat <<'EOF' - -Next, once per subscription (browser on this machine): - ./install.sh login claude -Then open T3 from the application menu, or log out and in to see it start on its own. -EOF + if [ "$full_run" -eq 1 ]; then + offer_claude_login + phase_check || check_status=$? + fi + print_next_steps + return "$check_status" } -main "$@" +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi From 7417c6015663d61c7f0e4136918e8180af3f78ef Mon Sep 17 00:00:00 2001 From: RS Nikhil Krishna Date: Thu, 8 Oct 2026 11:43:37 -0400 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20Install=20fastfetch=20where?= =?UTF-8?q?=20neofetch=20no=20longer=20exists=20(Ubuntu=2026.04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- 1-BasicSetUp.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/1-BasicSetUp.sh b/1-BasicSetUp.sh index 1d99ce4..764dd9c 100755 --- a/1-BasicSetUp.sh +++ b/1-BasicSetUp.sh @@ -33,7 +33,13 @@ if [[ ! -n $CIINSTALL ]]; then fi # Terminal multiplexer: byobu (tmux) -execute sudo apt-get install unzip git byobu magic-wormhole openssh-server python3-pip htop curl expect neofetch ffmpeg software-properties-common git-delta git-lfs -y +execute sudo apt-get install unzip git byobu magic-wormhole openssh-server python3-pip htop curl expect ffmpeg software-properties-common git-delta git-lfs -y +# neofetch is gone from Ubuntu 26.04; fastfetch is its successor +if apt-cache show neofetch >/dev/null 2>&1; then + execute sudo apt-get install neofetch -y +else + execute sudo apt-get install fastfetch -y +fi if ! is_ci; then execute sudo apt-get install xrdp -y execute sudo apt-get install xclip xsel -y # this is used for the copying tmux buffer to clipboard buffer From a95414b3f0e409272ab32ce685a9c939a279b675 Mon Sep 17 00:00:00 2001 From: RS Nikhil Krishna Date: Thu, 8 Oct 2026 11:50:13 -0400 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20Use=20the=20archive=20Neovim?= =?UTF-8?q?=20where=20it=20is=20new=20enough;=20PPA=20only=20when=20it=20i?= =?UTF-8?q?s=20not?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unstable PPA publishes an incomplete index for Ubuntu 26.04 (neovim-runtime not installable), and the 26.04 archive already ships 0.11.6, which LazyVim accepts. Co-Authored-By: Claude Sonnet 5.5 --- 1-BasicSetUp.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/1-BasicSetUp.sh b/1-BasicSetUp.sh index 764dd9c..253a1cc 100755 --- a/1-BasicSetUp.sh +++ b/1-BasicSetUp.sh @@ -234,8 +234,12 @@ else rm -rf ~/.local/share/nvim/ rm -rf ~/.config/nvim/ - sudo add-apt-repository ppa:neovim-ppa/unstable -y - sudo apt update + # LazyVim needs Neovim 0.11.2 or newer. Releases whose archive ships less get the unstable PPA. + nvim_candidate="$(apt-cache policy neovim | awk '/Candidate:/ {print $2}')" + if [ -z "$nvim_candidate" ] || [ "$nvim_candidate" = "(none)" ] || dpkg --compare-versions "$nvim_candidate" lt 0.11.2; then + sudo add-apt-repository ppa:neovim-ppa/unstable -y + sudo apt update + fi sudo apt install -y neovim git clone https://github.com/LazyVim/starter ~/.config/nvim