diff --git a/.github/assets/pi-grok-cli.png b/.github/assets/pi-grok-cli.png new file mode 100644 index 0000000..17d900a Binary files /dev/null and b/.github/assets/pi-grok-cli.png differ diff --git a/.gitignore b/.gitignore index b76e3ab..d211be7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ dist/ docs/ *.tgz .DS_Store +.agents +.impeccable +DESIGN.md +PRODUCT.md +.playwright-cli +.codex diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 8ac52ec..4fdc7b9 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -13,3 +13,5 @@ Most users do not need these overrides. Only point base URL overrides at endpoin | `PI_GROK_CLI_TOKEN_TIMEOUT_MS` | `30000` | Timeout for OAuth token requests. | | `PI_GROK_CLI_IMAGINE_BASE_URL` | `https://api.x.ai/v1` | Override the Imagine API base URL. | | `PI_GROK_CLI_IMAGINE_MODEL` | `grok-imagine-image-quality` | Override the Imagine image model. | + +OAuth logins store the effective main API base URL in each account's credentials. Changing or removing `PI_GROK_CLI_BASE_URL` or `GROK_CLI_BASE_URL` does not immediately redirect an already authenticated account; its stored URL remains active until token refresh or re-login. Log in again to apply the change immediately. diff --git a/README.md b/README.md index 88cd780..05fd13a 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,22 @@ [![npm version](https://img.shields.io/npm/v/pi-grok-cli?label=npm&color=blue)](https://www.npmjs.com/package/pi-grok-cli) [![License: MIT](https://img.shields.io/badge/License-MIT-red.svg)](./LICENSE) +
+ +[![Pi Grok CLI](./.github/assets/pi-grok-cli.png)](./.github/assets/pi-grok-cli.png) + +
+ Use your X Premium or SuperGrok subscription in [pi](https://pi.dev/) with a clean, focused toolset. -- **Vision for text-only models** — automatically describe images with a vision-capable Grok model and cache descriptions locally. +- **Subscription OAuth** — sign in through a browser or device code; tokens refresh automatically. +- **Multiple accounts** — keep independent Pi logins, switch from a TUI or browser UI, and rotate automatically on exhausted-balance error. +- **Usage tracking** — check account limits, remaining credits, and reset times from pi. - **Grok Imagine** — generate JPEGs from the TUI or let Grok call the `image_gen` tool, with inline previews. -- **Subscription OAuth** — sign in through a browser or device code or reuse an official Grok Build login; tokens refresh automatically. +- **Vision for text-only models** — automatically describe images with a vision-capable Grok model and cache descriptions locally. - **Model-scoped compatibility** — Cursor-style tool names only for Grok Build and Composer 2.5. All other models keep Pi's native tools. -- **Usage tracking** — check account limits, remaining credits, and reset times from pi. -> Requires pi 0.80.0 or newer and an xAI/Grok account with access to the selected model. Model availability varies by account, plan, region, and xAI rollout. The Grok Build executable is not required. +> Requires pi 0.80.9 or newer and an xAI/Grok account with access to the selected model. Model availability varies by account, plan, region, and xAI rollout. The Grok Build executable is not required. > > pi-grok-cli is an unofficial community integration. It does not bypass xAI access controls, quotas, or billing. @@ -39,7 +46,8 @@ Choose **Grok CLI**, then select one of these methods: - **Browser login (default)** — opens xAI authorization and completes a PKCE exchange through a local loopback callback. If xAI shows a one-time code instead of redirecting to pi, paste that code into pi to complete the active PKCE exchange. - **Device code login (headless)** — displays a URL and short code for SSH, containers, and other headless environments. -- **Use existing Grok Build login** — appears when a valid official `~/.grok/auth.json` entry is available. The file is read-only to this extension. + +To add another login, run `/grok-cli-accounts`, choose **+ Add account**, and optionally label it. The extension adds a stable provider alias and pre-fills `/login `; press Enter to complete Pi's native login flow. For a browser UI with an account and quota overview, run `/grok-cli-accounts gui`; adding an account there starts browser login immediately. Use only accounts you own or are authorized to access. ### 3. Select a model @@ -71,7 +79,7 @@ The table describes metadata bundled with this extension, not live model discove | Model ID | Registered context | Reasoning | Input | Coding tools | | --- | ---: | --- | --- | --- | | `grok-composer-2.5-fast` | 200K | no | text; images can be described through vision routing | Cursor-compatible names | -| `grok-build` | 512K | yes | text + image | Cursor-compatible names | +| `grok-build` | 500K | yes | text + image | Cursor-compatible names | | `grok-4.3` | 1M | yes | text + image | native pi names | | `grok-4.5` | 500K | yes | text + image | native pi names | | `grok-4.20-0309-reasoning` | 2M | yes | text + image | native pi names | @@ -82,7 +90,27 @@ The table describes metadata bundled with this extension, not live model discove ### OAuth authentication -Use `/login` to authenticate through a browser or device code, or reuse an official Grok Build login. Pi stores and refreshes OAuth credentials automatically. For automation, `GROK_CLI_OAUTH_TOKEN` supplies a direct token without automatic refresh. +Use `/login` to authenticate through a browser or device code. Pi stores and refreshes OAuth credentials automatically. For automation, `GROK_CLI_OAUTH_TOKEN` supplies a direct token without automatic refresh. + +Run `/grok-cli-accounts` to add, switch, rename, relogin, log out, or remove accounts. Each account is registered as an independent Pi provider (`grok-cli`, `grok-cli-2`, and so on), so Pi keeps its OAuth credential separately in `auth.json`. The base `grok-cli` slot is permanent; aliases can be removed. Existing aliases keep their provider IDs, and a removed alias number is reused by the next account when it is the lowest available slot. + +Run `/grok-cli-accounts gui` to open the same account controls in a local browser UI with monthly and weekly quota meters. The browser UI binds to a random `127.0.0.1` port for the current Pi session, completes browser login without exposing credentials to the page, and closes on Pi session shutdown or after 15 minutes without requests. Its private local URL grants access to account controls while the server is running; do not share it. Browser login and manual one-time-code entry are supported in the browser UI, while device-code login remains available through the TUI for headless environments. + +The account selectors show the last cached quota for every logged-in account as consumed monthly and weekly usage. Press `r` to refresh all logged-in accounts without switching models or changing the selected account. Refreshes run three accounts at a time; a failed account keeps its previous cached value and is marked as failed for the current dialog. Values older than 30 minutes are labeled stale. Opening the selector does not refresh automatically. + +`GROK_CLI_OAUTH_TOKEN` applies only to the permanent base account. When it is set, the accounts UI identifies the environment token and tells you to unset it instead of presenting a logout action that cannot remove it. + +#### Automatic exhaustion rotation + +When at least two configured accounts are logged in, pi-grok-cli rotates after a final Grok assistant error whose trimmed message is exactly: + +```text +OpenAI API error (402): 402 "Grok Build usage balance exhausted" +``` + +Rotation waits until Pi has finished retries, compaction, and tool activity. It preserves the current model when available and automatically continues the interrupted request once. Accounts that returned the exact error are skipped for five minutes within the current Pi session, including across new requests and successful continuations. A successful relogin makes that account immediately eligible again. If every logged-in account is exhausted or still cooling down, the extension stops without wrapping and reports that all accounts are exhausted. + +Within the eligible circular fallback order, quota values cached less than 30 minutes ago rank candidates by their tightest remaining monthly or weekly percentage. Candidates with stale, missing, or invalid quota keep their circular slots, and equal scores keep circular order. Failover reads the cache only—it never fetches billing data—and quota ranking never rotates a healthy account or chooses the initial account. Similar 402 messages, 401s, 429s, non-Grok errors, and non-final errors do not trigger rotation. A manual model change before failover cancels the pending rotation. ### Model-scoped Cursor compatibility @@ -91,6 +119,8 @@ Pi's native tool vocabulary remains the default. Cursor-compatible tool names ar - `grok-cli/grok-build` - `grok-cli/grok-composer-2.5-fast` +Managed account aliases expose the same two model IDs with the same compatibility behavior. + Only these two models get Cursor-compatible tool names. Grok 4.5, future Grok models, and other providers continue to use Pi's native tools. No extra shims. | Compatibility tools | Implementation | @@ -117,7 +147,7 @@ When `read` or `Read` returns an image to a text-only model, pi-grok-cli describ ### Grok Imagine image generation -Run `/grok-cli-imagine ` to generate and preview a JPEG, or let any active model call the `image_gen` tool. Both use the current Grok CLI OAuth token and save the result under the current session or a requested output path. The command supports `--aspect`, `--out`, and `--resolution 1k`. +Run `/grok-cli-imagine ` to generate and preview a JPEG, or let any active model call the `image_gen` tool. Both use the current or last successfully selected Grok account and save the result under the current session or a requested output path. The command supports `--aspect`, `--out`, and `--resolution 1k`. `image_gen` is enabled by default across providers and is independent of Cursor compatibility. Use `/grok-cli-imagine:tool [on|off|status]` to manage its persisted availability without disabling the direct command. @@ -125,20 +155,42 @@ Run `/grok-cli-imagine ` to generate and preview a JPEG, or let any acti | Command | Description | | --- | --- | -| `/grok-cli-usage` | Fetch current quota, remaining credits, and reset times. | +| `/grok-cli-accounts [gui]` | Manage Grok accounts in the TUI, or add `gui` for the local browser UI with account and quota controls. | +| `/grok-cli-usage` | Fetch current quota, update its cache, and show cached data if refresh fails. | | `/grok-cli-imagine ` | Generate and preview an image. Supports `--aspect`, `--out`, and `--resolution 1k`. | | `/grok-cli-imagine:tool [on\|off\|status]` | Toggle, set, or report persistent model-callable `image_gen` availability. | +| `/grok-cli-vision` | Toggle vision routing on or off. | | `/grok-cli-vision:status` | Show vision state, describer model, configuration path, and cache statistics. | -| `/grok-cli-vision:on` / `/grok-cli-vision:off` | Enable or disable vision routing. | | `/grok-cli-vision:cache-clear` | Remove all cached image descriptions. | ## Configuration -All extension settings are read from `~/.pi/grok-cli.json`. The file is created after the first setting change and defaults to: +Extension-owned data is grouped under `~/.pi/grok-cli/`: + +```text +~/.pi/grok-cli/ +├── config.json +├── vision-cache.json +└── quota-cache.json +``` + +`config.json` contains settings and account labels. The two cache files contain derived vision descriptions and quota responses; neither contains OAuth tokens. Pi owns OAuth credentials in its `auth.json`; this extension does not read Grok Build credentials. + +The configuration file is created after the first setting change and defaults to: ```json { - "version": 1, + "version": 2, + "accounts": { + "nextAccountNumber": 2, + "selectedProvider": "grok-cli", + "items": [ + { + "provider": "grok-cli", + "label": "Account 1" + } + ] + }, "imagine": { "enabled": true }, @@ -152,7 +204,7 @@ All extension settings are read from `~/.pi/grok-cli.json`. The file is created } ``` -Legacy Imagine and vision settings are migrated automatically. The vision cache remains separate at `~/.pi/grok-cli-vision-cache.json`. +Existing `~/.pi/grok-cli.json`, `~/.pi/grok-cli-vision-cache.json`, version 1 configuration, and legacy Imagine and vision settings are migrated atomically while preserving feature settings and cache data. A conflicting file at the new location remains authoritative and the legacy file is preserved with a warning instead of being overwritten. `model` must identify an image-capable model. Invalid values are reported and replaced with safe defaults. Manual changes apply on the next image read. @@ -170,7 +222,7 @@ See [Advanced configuration](./CONFIGURATION.md) for OAuth, callback, endpoint, | Problem | What to do | | --- | --- | | grok-cli is missing from `/model` | Confirm the package appears in `pi list`, run `/login`, choose **Grok CLI**, then restart pi or run `/reload`. | -| Browser login cannot bind or complete | Paste the complete callback URL into pi when prompted. If xAI displays a one-time code instead, paste it into the same prompt. Otherwise, use device-code login or adjust `PI_GROK_CLI_CALLBACK_HOST` and `PI_GROK_CLI_CALLBACK_PORT`. Never post the callback URL or authorization code publicly. | +| Browser login cannot bind or complete | If the callback listener started but the browser redirect does not complete, paste the complete callback URL into pi when prompted. If xAI displays a one-time code instead, paste it into the same prompt. If pi reports that the listener could not bind, use device-code login or adjust `PI_GROK_CLI_CALLBACK_HOST` and `PI_GROK_CLI_CALLBACK_PORT`. Never post the callback URL or authorization code publicly. | | Authentication returns HTTP 401 or 403 | Run `/login` again and confirm the account can access the selected model. Replace an expired `GROK_CLI_OAUTH_TOKEN` if using the bypass. | | A listed model is unavailable | Availability can differ by account or region, and the catalog is bundled rather than discovered live. Try another model or update the extension. | | Images are not being described | Run `/grok-cli-vision:status`, confirm routing is on, and verify Grok authentication. Native image-capable models bypass routing by design. | diff --git a/SECURITY.md b/SECURITY.md index 228e4d7..5d9a7c7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,11 +18,15 @@ The maintainer aims to acknowledge a complete report within 7 calendar days and ## Security boundaries -- pi-grok-cli and its tools run with the user's operating-system permissions. File and shell tools can read or modify the workspace and execute commands when the model invokes them. -- OAuth credentials returned by this extension are stored and refreshed by pi. pi-grok-cli may read the verified official Grok CLI entry in `~/.grok/auth.json`, but never writes to that file. -- Prompts, conversation context, tool definitions, and tool results are sent to the configured Grok CLI proxy. +- pi-grok-cli and its tools run with the user's operating-system permissions. File and shell tools can access any path those permissions allow, including paths outside the workspace, and can execute commands when the model invokes them. +- OAuth credentials returned by this extension are stored and refreshed by pi. pi-grok-cli does not read or modify Grok Build credentials. +- Browser OAuth starts a temporary callback server on `127.0.0.1:56122` by default and falls back to an ephemeral port. `PI_GROK_CLI_CALLBACK_HOST` can bind it to another interface. The server validates the callback path and OAuth state and closes after login. Treat complete callback URLs and authorization codes as sensitive. +- `/grok-cli-accounts gui` starts a temporary account-management server bound only to an OS-assigned `127.0.0.1` port. A random capability URL bootstraps a session cookie; subsequent mutations require same-origin and CSRF validation. The page receives account labels, status, and quota data. It never receives stored OAuth credentials, callback URLs, or environment-token values; a manually entered one-time authorization code is handled transiently during login and is never returned by `/api/state`. Treat the private dashboard URL as sensitive and do not share it while the server is running. +- Prompts, conversation context, tool definitions, tool results, and native image inputs are sent to the configured Grok CLI proxy. - Images handled by vision routing are sent to the configured describer model. The local vision cache stores descriptions and hashes, not raw images. +- Grok Imagine sends the selected account's bearer token, generation prompt, and options to `https://api.x.ai/v1` or `PI_GROK_CLI_IMAGINE_BASE_URL`. Generated JPEGs and PNG previews are saved under session storage, a requested output path, or temporary storage. +- Monthly and weekly quota totals and reset timestamps are cached per account in `~/.pi/grok-cli/quota-cache.json` with file mode `0600`. The cache does not contain OAuth tokens. - Optional web search is delegated to `pi-web-access` and the providers configured there. -- A configured base URL override is trusted with bearer tokens, prompts, conversation data, and tool results. +- A configured main API base URL override is trusted with bearer tokens, prompts, conversation data, tool results, images, and billing queries. An Imagine base URL override is trusted with the bearer token and generation request described above. Expected provider behavior, account-specific model availability, subscription quotas, model-generated content, a model choosing an enabled local tool, and changes made after a user deliberately configures an untrusted endpoint are generally out of scope unless pi-grok-cli violates a documented boundary or exposes data beyond that configuration. diff --git a/bun.lock b/bun.lock index 46a1d4e..97d1550 100644 --- a/bun.lock +++ b/bun.lock @@ -10,11 +10,12 @@ }, "devDependencies": { "@biomejs/biome": "2.4.16", - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-coding-agent": "^0.80.6", - "@earendil-works/pi-tui": "^0.80.6", + "@earendil-works/pi-agent-core": "^0.80.9", + "@earendil-works/pi-ai": "^0.80.9", + "@earendil-works/pi-coding-agent": "^0.80.9", + "@earendil-works/pi-tui": "^0.80.9", "@vitest/coverage-v8": "^4.1.8", + "happy-dom": "^20.10.6", "husky": "^9.1.7", "jscpd": "^4.2.4", "knip": "^6.15.0", @@ -24,9 +25,9 @@ "vitest": "^4.1.8", }, "peerDependencies": { - "@earendil-works/pi-ai": ">=0.80.0", - "@earendil-works/pi-coding-agent": ">=0.80.0", - "@earendil-works/pi-tui": ">=0.80.0", + "@earendil-works/pi-ai": ">=0.80.9", + "@earendil-works/pi-coding-agent": ">=0.80.9", + "@earendil-works/pi-tui": ">=0.80.9", "pi-web-access": ">=0.13.0", }, "optionalPeers": [ @@ -119,13 +120,13 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.80.6", "", { "dependencies": { "@earendil-works/pi-ai": "^0.80.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-Lvn89ko42h5ETUb6Z0Ku6ldskEqXaTdQBYvSa0+7bdG9V6rUEpXptv5e0OVZ1HDcvi8s6/2lGCQWsxKX+DFHNw=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.80.9", "", { "dependencies": { "@earendil-works/pi-ai": "^0.80.9", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-tObjeOLiw1kYUciBi9R+rRyc4QGK+1akbLLQHvzsn2JrrV2btUdDncJ7jMIR5TKvOYKzKxAwQSl/5k7h3Tjrrg=="], - "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.6", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-7xfLk8sANBp+bpPEbjoOZTbPxsa+++b1JXAoSJsNa3vbs9AHHEclmvg54XLQcxH+fuwaeti/g2jeIfJ+mVYLpA=="], + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.9", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-kHsH5nO4FU7mbKnskK0BVPVuWzNb2DrZtiN1fb6LamP+6BMI8xEZiAOw2fqs4VudvlMQgOLjtbgErv+kNJRPIg=="], - "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.80.6", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.80.6", "@earendil-works/pi-ai": "^0.80.6", "@earendil-works/pi-tui": "^0.80.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-vcfD6tOk402isLl3Cm/qbn2O10TvgroMp1+/fEGM24ZdvETFCdOYv5VZ7m59EI5fPsjfSJh+CpQ5bhBrhfOg7g=="], + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.80.9", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.80.9", "@earendil-works/pi-ai": "^0.80.9", "@earendil-works/pi-tui": "^0.80.9", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-Clgx2Bg5NbMcCpGxusSDQwE+GC0g/d6sCBluE9aypPgSgtJ6n8VmZIIT6auXObMskpRgkr+XZ77wG5hf+cSDtg=="], - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.80.6", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-bSuzS4EVSqEPj/Qr/p9eqCESfKsGuDNbl77EGci8Iaqqt/C/XCBZL1MjXaxSWW1NsT5afjp/Cb0NTPzOLv/aPA=="], + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.80.9", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-unPTW8hRgIHEGjV8mJJ2jqm+fzgnRubes6V2FPk9ay1W9ZLofcpYQ3NDfrODXSci+oKbBpX9JyYUMfQV6jCA/A=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -357,6 +358,10 @@ "@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="], @@ -411,6 +416,8 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -457,6 +464,8 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -529,6 +538,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -843,6 +854,8 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], diff --git a/package.json b/package.json index 2357e61..4be59c4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "type": "module", "main": "./src/index.ts", "files": [ + "CONFIGURATION.md", "README.md", "SECURITY.md", "src", @@ -53,9 +54,9 @@ ] }, "peerDependencies": { - "@earendil-works/pi-ai": ">=0.80.0", - "@earendil-works/pi-coding-agent": ">=0.80.0", - "@earendil-works/pi-tui": ">=0.80.0", + "@earendil-works/pi-ai": ">=0.80.9", + "@earendil-works/pi-coding-agent": ">=0.80.9", + "@earendil-works/pi-tui": ">=0.80.9", "pi-web-access": ">=0.13.0" }, "peerDependenciesMeta": { @@ -65,11 +66,12 @@ }, "devDependencies": { "@biomejs/biome": "2.4.16", - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-coding-agent": "^0.80.6", - "@earendil-works/pi-tui": "^0.80.6", + "@earendil-works/pi-agent-core": "^0.80.9", + "@earendil-works/pi-ai": "^0.80.9", + "@earendil-works/pi-coding-agent": "^0.80.9", + "@earendil-works/pi-tui": "^0.80.9", "@vitest/coverage-v8": "^4.1.8", + "happy-dom": "^20.10.6", "husky": "^9.1.7", "jscpd": "^4.2.4", "knip": "^6.15.0", diff --git a/src/auth/grokCredentials.ts b/src/auth/grokCredentials.ts deleted file mode 100644 index 99ee99b..0000000 --- a/src/auth/grokCredentials.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { getBaseUrl, XAI_ISSUER, XAI_OAUTH_CLIENT_ID, XAI_TOKEN_ENDPOINT } from './config.js'; -import type { XaiOAuthCredentials } from './oauth.js'; - -function expiryMilliseconds(value: unknown) { - if (typeof value === 'number') { - return Number.isInteger(value) && value >= 1_000_000_000_000 && value < 100_000_000_000_000 - ? value - : undefined; - } - if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(value)) return undefined; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -export function parseGrokCredentials(value: unknown): XaiOAuthCredentials | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const entry = (value as Record)[`${XAI_ISSUER}::${XAI_OAUTH_CLIENT_ID}`]; - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return undefined; - - const record = entry as Record; - const expires = expiryMilliseconds(record.expires_at); - if ( - typeof record.key !== 'string' || - !record.key || - typeof record.refresh_token !== 'string' || - !record.refresh_token || - record.oidc_issuer !== XAI_ISSUER || - record.oidc_client_id !== XAI_OAUTH_CLIENT_ID || - expires === undefined - ) { - return undefined; - } - - return { - access: record.key, - refresh: record.refresh_token, - expires, - tokenEndpoint: XAI_TOKEN_ENDPOINT, - baseUrl: getBaseUrl(), - }; -} - -export async function readGrokCredentials() { - try { - return parseGrokCredentials( - JSON.parse(await readFile(join(homedir(), '.grok', 'auth.json'), 'utf8')), - ); - } catch { - return undefined; - } -} diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index 7dba5c4..caa3d00 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -12,7 +12,6 @@ import { createServer } from 'node:http'; import { XaiErrorCode, XaiOAuthError } from '../shared/errors.js'; import { getBaseUrl, XAI_ISSUER, XAI_OAUTH_CLIENT_ID } from './config.js'; -import { readGrokCredentials } from './grokCredentials.js'; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -621,48 +620,21 @@ async function loginWithBrowserCallback() { export async function login( callbacks: import('@earendil-works/pi-ai').OAuthLoginCallbacks, - credentialReader: typeof readGrokCredentials = readGrokCredentials, ): Promise { - const existing = await credentialReader(); const hasDeviceLoginUi = typeof callbacks.onSelect === 'function' && typeof (callbacks as { onDeviceCode?: unknown }).onDeviceCode === 'function'; - const existingMethod = - existing && typeof callbacks.onSelect === 'function' - ? await callbacks.onSelect({ - message: 'Select Grok CLI login method:', - options: [ - { id: 'browser', label: 'Browser login (default)' }, - ...(hasDeviceLoginUi ? [{ id: 'device', label: 'Device code login (headless)' }] : []), - { id: 'existing', label: 'Use existing Grok Build login' }, - ], - }) - : undefined; - if (existing && existingMethod === 'existing') { - if (existing.expires > Date.now()) return existing; - try { - return await refresh(existing); - } catch { - callbacks.onProgress?.( - 'Existing Grok CLI login could not be refreshed. Choose a fresh login method.', - ); - } - } - const discovery = await discover(); const supportsDeviceLogin = Boolean(discovery.device_authorization_endpoint && hasDeviceLoginUi); - const method = - existingMethod === 'browser' || existingMethod === 'device' - ? existingMethod - : supportsDeviceLogin - ? await callbacks.onSelect({ - message: 'Select Grok CLI login method:', - options: [ - { id: 'browser', label: 'Browser login (default)' }, - { id: 'device', label: 'Device code login (headless)' }, - ], - }) - : 'browser'; + const method = supportsDeviceLogin + ? await callbacks.onSelect({ + message: 'Select Grok CLI login method:', + options: [ + { id: 'browser', label: 'Browser login (default)' }, + { id: 'device', label: 'Device code login (headless)' }, + ], + }) + : 'browser'; if (!method) throw new Error('Login cancelled'); if (method === 'device') return loginWithDeviceCode(discovery, callbacks); diff --git a/src/config.ts b/src/config.ts index 5fa6891..dfcc01c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,18 +1,20 @@ -import { randomUUID } from 'node:crypto'; -import { - existsSync, - mkdirSync, - readFileSync, - renameSync, - rmSync, - unlinkSync, - writeFileSync, -} from 'node:fs'; -import { homedir } from 'node:os'; -import { basename, dirname, join } from 'node:path'; +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; import { resolveModels } from './models/catalog.js'; - -export const CONFIG_VERSION = 1 as const; +import { + getConfigPath, + getLegacyConfigPath, + getLegacyImagineConfigPath, + getLegacyVisionCachePath, + getLegacyVisionConfigPath, + getVisionCachePath, + migrateStoredFile, + writeFileAtomic, +} from './storage.js'; + +export { getConfigPath, getLegacyImagineConfigPath, getLegacyVisionConfigPath } from './storage.js'; + +export const CONFIG_VERSION = 2 as const; +const LEGACY_CONFIG_VERSION = 1; export const DEFAULT_DESCRIBE_MODEL = 'grok-build'; export const DEFAULT_MAX_IMAGES = 4; export const DEFAULT_CACHE_MAX_ENTRIES = 100; @@ -27,8 +29,20 @@ export interface VisionConfig { cacheMaxEntries: number; } +export interface GrokCliAccount { + provider: string; + label: string; +} + +export interface AccountsConfig { + nextAccountNumber: number; + selectedProvider: string; + items: GrokCliAccount[]; +} + export interface GrokCliConfig { version: typeof CONFIG_VERSION; + accounts: AccountsConfig; imagine: ImagineConfig; vision: VisionConfig; } @@ -43,8 +57,15 @@ export const DEFAULT_VISION_CONFIG: VisionConfig = { cacheMaxEntries: DEFAULT_CACHE_MAX_ENTRIES, }; +export const DEFAULT_ACCOUNTS_CONFIG: AccountsConfig = { + nextAccountNumber: 2, + selectedProvider: 'grok-cli', + items: [{ provider: 'grok-cli', label: 'Account 1' }], +}; + export const DEFAULT_CONFIG: GrokCliConfig = { version: CONFIG_VERSION, + accounts: DEFAULT_ACCOUNTS_CONFIG, imagine: DEFAULT_IMAGINE_CONFIG, vision: DEFAULT_VISION_CONFIG, }; @@ -54,22 +75,20 @@ export interface LoadedConfig { warning?: string; } -type ParsedConfig = LoadedConfig & { valid: boolean }; +type ParsedConfig = LoadedConfig & { valid: boolean; needsMigration: boolean }; type LegacyConfig = LoadedConfig & { existingPaths: string[]; recognizedPaths: string[]; }; -const homePath = () => process.env.HOME || homedir(); - -export const getConfigPath = () => join(homePath(), '.pi', 'grok-cli.json'); -export const getLegacyImagineConfigPath = () => join(homePath(), '.pi', 'grok-cli-imagine.json'); -export const getLegacyVisionConfigPath = () => join(homePath(), '.pi', 'grok-cli-vision.json'); - function defaultConfig(): GrokCliConfig { return { version: CONFIG_VERSION, + accounts: { + ...DEFAULT_ACCOUNTS_CONFIG, + items: DEFAULT_ACCOUNTS_CONFIG.items.map((account) => ({ ...account })), + }, imagine: { ...DEFAULT_IMAGINE_CONFIG }, vision: { ...DEFAULT_VISION_CONFIG }, }; @@ -88,6 +107,96 @@ function combineWarnings(warnings: (string | undefined)[]) { return combined.length ? combined.join(' ') : undefined; } +export function hasTerminalControlCharacters(value: string) { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || (code >= 127 && code <= 159); + }); +} + +const accountNumber = (provider: string) => { + if (provider === 'grok-cli') return 1; + const match = /^grok-cli-((?:[2-9]|[1-9]\d+))$/.exec(provider); + return match ? Number(match[1]) : undefined; +}; + +export function findAvailableAccountNumber( + providers: Iterable, + reservedProviders: Iterable = [], +) { + const unavailable = new Set([...providers, ...reservedProviders]); + const find = (number: number): number => + unavailable.has(`grok-cli-${number}`) ? find(number + 1) : number; + return find(2); +} + +function normalizeAccountsConfig(raw: unknown, warnings: string[]): AccountsConfig { + if (raw === undefined) return defaultConfig().accounts; + if (!isObject(raw) || !Array.isArray(raw.items)) { + warnings.push('accounts must be an object with an items array. Using defaults.'); + return defaultConfig().accounts; + } + + const invalid: unknown[] = []; + const providers = new Set(); + const labels = new Set(); + const baseIndex = raw.items.findIndex((value) => { + if (!isObject(value) || value.provider !== 'grok-cli' || typeof value.label !== 'string') { + return false; + } + const label = value.label.trim(); + return Boolean(label) && [...label].length <= 40 && !hasTerminalControlCharacters(label); + }); + const accountValues = + baseIndex >= 0 + ? [raw.items[baseIndex], ...raw.items.filter((_value, index) => index !== baseIndex)] + : [{ provider: 'grok-cli', label: 'Account 1' }, ...raw.items]; + const items = accountValues.flatMap((value) => { + if (!isObject(value) || typeof value.provider !== 'string' || typeof value.label !== 'string') { + invalid.push(value); + return []; + } + const label = value.label.trim(); + const normalizedLabel = label.toLocaleLowerCase(); + if ( + accountNumber(value.provider) === undefined || + !label || + [...label].length > 40 || + hasTerminalControlCharacters(label) || + providers.has(value.provider) || + labels.has(normalizedLabel) + ) { + invalid.push(value); + return []; + } + providers.add(value.provider); + labels.add(normalizedLabel); + return [{ provider: value.provider, label }]; + }); + + if (!providers.has('grok-cli')) { + items.unshift({ provider: 'grok-cli', label: 'Account 1' }); + providers.add('grok-cli'); + } else { + items.sort((left, right) => + left.provider === 'grok-cli' ? -1 : right.provider === 'grok-cli' ? 1 : 0, + ); + } + + if (invalid.length) + warnings.push('accounts contains invalid or duplicate entries. Ignoring them.'); + const selectedProvider = + typeof raw.selectedProvider === 'string' && providers.has(raw.selectedProvider) + ? raw.selectedProvider + : 'grok-cli'; + + return { + nextAccountNumber: findAvailableAccountNumber(providers), + selectedProvider, + items, + }; +} + export function describableModels(): string[] { return resolveModels() .filter((model) => model.input.includes('image')) @@ -170,7 +279,7 @@ export function normalizeVisionConfig( } function normalizeConfig( - raw: { imagine?: unknown; vision?: unknown }, + raw: { accounts?: unknown; imagine?: unknown; vision?: unknown }, warnings: string[], ): GrokCliConfig { const vision = raw.vision; @@ -179,6 +288,7 @@ function normalizeConfig( } return { version: CONFIG_VERSION, + accounts: normalizeAccountsConfig(raw.accounts, warnings), imagine: normalizeImagineConfig(raw.imagine, warnings), vision: normalizeVisionConfig(isObject(vision) ? vision : {}, warnings), }; @@ -190,13 +300,15 @@ function parseConfig(configPath: string): ParsedConfig { if (!isObject(parsed)) { return { config: defaultConfig(), + needsMigration: false, valid: false, warning: `Config ${configPath} must be a JSON object. Using legacy settings or defaults.`, }; } - if (parsed.version !== CONFIG_VERSION) { + if (parsed.version !== CONFIG_VERSION && parsed.version !== LEGACY_CONFIG_VERSION) { return { config: defaultConfig(), + needsMigration: false, valid: false, warning: `Unsupported config version ${String(parsed.version)} in ${configPath}. Using legacy settings or defaults.`, }; @@ -204,12 +316,14 @@ function parseConfig(configPath: string): ParsedConfig { const warnings: string[] = []; return { config: normalizeConfig(parsed, warnings), + needsMigration: parsed.version === LEGACY_CONFIG_VERSION, valid: true, warning: warnings.length ? `Invalid ${configPath}: ${warnings.join(' ')}` : undefined, }; } catch (error) { return { config: defaultConfig(), + needsMigration: false, valid: false, warning: `Could not read ${configPath}: ${errorMessage(error)}. Using legacy settings or defaults.`, }; @@ -295,6 +409,7 @@ function loadLegacyConfig(): LegacyConfig { return { config: { version: CONFIG_VERSION, + accounts: defaultConfig().accounts, imagine: imagine?.config ?? { ...DEFAULT_IMAGINE_CONFIG }, vision: vision?.config ?? { ...DEFAULT_VISION_CONFIG }, }, @@ -310,13 +425,18 @@ function loadLegacyConfig(): LegacyConfig { } export function loadConfig(): LoadedConfig { - if (!existsSync(getConfigPath())) { + const configPath = existsSync(getConfigPath()) + ? getConfigPath() + : existsSync(getLegacyConfigPath()) + ? getLegacyConfigPath() + : undefined; + if (!configPath) { const legacy = loadLegacyConfig(); return legacy.warning ? { config: legacy.config, warning: legacy.warning } : { config: legacy.config }; } - const loaded = parseConfig(getConfigPath()); + const loaded = parseConfig(configPath); if (loaded.valid) { return loaded.warning ? { config: loaded.config, warning: loaded.warning } @@ -330,22 +450,7 @@ export function loadConfig(): LoadedConfig { } export function saveConfig(config: GrokCliConfig) { - const configPath = getConfigPath(); - const tempPath = join( - dirname(configPath), - `.${basename(configPath)}.${process.pid}.${randomUUID()}.tmp`, - ); - mkdirSync(dirname(configPath), { recursive: true }); - try { - writeFileSync(tempPath, `${JSON.stringify(normalizeConfig(config, []), null, 2)}\n`, { - encoding: 'utf8', - flag: 'wx', - }); - renameSync(tempPath, configPath); - } catch (error) { - rmSync(tempPath, { force: true }); - throw error; - } + writeFileAtomic(getConfigPath(), `${JSON.stringify(normalizeConfig(config, []), null, 2)}\n`); } function removeLegacyConfigs(paths: string[]) { @@ -362,19 +467,63 @@ function removeLegacyConfigs(paths: string[]) { } export function migrateLegacyConfig(): { warning?: string } { + const migratedLegacyConfig = existsSync(getLegacyConfigPath()) && !existsSync(getConfigPath()); + const storageWarning = combineWarnings([ + migrateStoredFile(getLegacyConfigPath(), getConfigPath(), true), + migrateStoredFile(getLegacyVisionCachePath(), getVisionCachePath()), + ]); + if (!existsSync(getConfigPath()) && existsSync(getLegacyConfigPath())) { + return { warning: storageWarning }; + } const legacy = loadLegacyConfig(); if (existsSync(getConfigPath())) { const loaded = parseConfig(getConfigPath()); if (!loaded.valid) { - return { warning: combineWarnings([loaded.warning, legacy.warning]) }; + return { warning: combineWarnings([storageWarning, loaded.warning, legacy.warning]) }; } - const cleanupWarning = removeLegacyConfigs(legacy.recognizedPaths); - const warning = combineWarnings([loaded.warning, legacy.warning, cleanupWarning]); + if (loaded.needsMigration) { + try { + saveConfig(loaded.config); + const verified = parseConfig(getConfigPath()); + if ( + !verified.valid || + verified.needsMigration || + JSON.stringify(verified.config) !== JSON.stringify(loaded.config) + ) { + return { + warning: combineWarnings([ + storageWarning, + verified.warning, + `Could not verify migrated config ${getConfigPath()}. Legacy files were preserved.`, + ]), + }; + } + } catch (error) { + return { + warning: combineWarnings([ + storageWarning, + `Could not migrate configuration ${getConfigPath()}: ${errorMessage(error)}. Legacy files were preserved.`, + ]), + }; + } + } + const cleanupWarning = removeLegacyConfigs([ + ...legacy.recognizedPaths, + ...(migratedLegacyConfig ? [getLegacyConfigPath()] : []), + ]); + const warning = combineWarnings([ + storageWarning, + loaded.warning, + legacy.warning, + cleanupWarning, + ]); return warning ? { warning } : {}; } - if (legacy.existingPaths.length === 0) return {}; + if (legacy.existingPaths.length === 0) { + return storageWarning ? { warning: storageWarning } : {}; + } if (legacy.recognizedPaths.length !== legacy.existingPaths.length) { - return { warning: legacy.warning }; + return { warning: combineWarnings([storageWarning, legacy.warning]) }; } try { saveConfig(legacy.config); @@ -382,6 +531,7 @@ export function migrateLegacyConfig(): { warning?: string } { if (!verified.valid || JSON.stringify(verified.config) !== JSON.stringify(legacy.config)) { return { warning: combineWarnings([ + storageWarning, verified.warning, `Could not verify migrated config ${getConfigPath()}. Legacy files were preserved.`, ]), @@ -389,9 +539,12 @@ export function migrateLegacyConfig(): { warning?: string } { } } catch (error) { return { - warning: `Could not migrate legacy configuration to ${getConfigPath()}: ${errorMessage(error)}. Legacy files were preserved.`, + warning: combineWarnings([ + storageWarning, + `Could not migrate legacy configuration to ${getConfigPath()}: ${errorMessage(error)}. Legacy files were preserved.`, + ]), }; } - const warning = removeLegacyConfigs(legacy.recognizedPaths); + const warning = combineWarnings([storageWarning, removeLegacyConfigs(legacy.recognizedPaths)]); return warning ? { warning } : {}; } diff --git a/src/imagine/auth.ts b/src/imagine/auth.ts index e68d87d..2ee61fe 100644 --- a/src/imagine/auth.ts +++ b/src/imagine/auth.ts @@ -1,12 +1,8 @@ import type { ExtensionContext } from '@earendil-works/pi-coding-agent'; +import { resolveGrokToken } from '../provider/accounts.js'; export async function resolveImagineToken(ctx: ExtensionContext) { - if (process.env.GROK_CLI_OAUTH_TOKEN) return process.env.GROK_CLI_OAUTH_TOKEN; - try { - return await ctx.modelRegistry.getApiKeyForProvider('grok-cli'); - } catch { - return undefined; - } + return resolveGrokToken(ctx); } export const IMAGINE_AUTH_ERROR = diff --git a/src/models/catalog.ts b/src/models/catalog.ts index 9e5b578..6d2c792 100644 --- a/src/models/catalog.ts +++ b/src/models/catalog.ts @@ -58,7 +58,7 @@ const FALLBACK_MODELS: GrokCliModelConfig[] = [ reasoning: true, input: ['text', 'image'], cost: COST_BUILD, - contextWindow: 512_000, + contextWindow: 500_000, maxTokens: 30_000, }, { @@ -118,11 +118,22 @@ const FALLBACK_MODELS: GrokCliModelConfig[] = [ const EFFORT_CAPABLE_PREFIXES = ['grok-3-mini', 'grok-4.20-multi-agent', 'grok-4.3', 'grok-4.5']; +const normalizedModelName = (modelId: string) => + (modelId.split('/').at(-1) ?? modelId).toLowerCase(); + +const modelConfig = (modelId: string) => { + const name = normalizedModelName(modelId); + return resolveModels().find((entry) => entry.id.toLowerCase() === name); +}; + +export function supportsReasoning(modelId: string): boolean { + return modelConfig(modelId)?.reasoning ?? true; +} + export function supportsReasoningEffort(modelId: string): boolean { - const parts = modelId.split('/'); - const name = parts.at(-1) ?? modelId; - const model = resolveModels().find((entry) => entry.id.toLowerCase() === name.toLowerCase()); - if (!EFFORT_CAPABLE_PREFIXES.some((prefix) => name.toLowerCase().startsWith(prefix))) { + const name = normalizedModelName(modelId); + const model = modelConfig(modelId); + if (!EFFORT_CAPABLE_PREFIXES.some((prefix) => name.startsWith(prefix))) { return false; } if (!model?.reasoning) return false; diff --git a/src/payload/sanitize.ts b/src/payload/sanitize.ts index e24f7ab..e085c95 100644 --- a/src/payload/sanitize.ts +++ b/src/payload/sanitize.ts @@ -2,7 +2,7 @@ * Payload sanitization for xAI's Responses API via cli-chat-proxy.grok.com. * * xAI's endpoint has quirks compared to stock OpenAI: - * - Replayed `reasoning` items in input cause 400 errors. + * - Replayed `reasoning` items must drop output-only status and carry typed content. * - `reasoning.effort` is only supported on a subset of models. * - Empty-string content items cause validation failures. * - `function_call_output.output` cannot contain image arrays. @@ -22,7 +22,9 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { extname, isAbsolute, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { supportsReasoningEffort } from '../models/catalog.js'; +import { supportsReasoning, supportsReasoningEffort } from '../models/catalog.js'; + +const ENCRYPTED_REASONING_INCLUDE = 'reasoning.encrypted_content'; // ─── Content text extraction ───────────────────────────────────────────────── @@ -224,6 +226,26 @@ function rewriteFunctionCallOutput(input: Record[]): Record { + if (typeof part === 'string') { + return part ? [{ type: 'reasoning_text', text: part }] : []; + } + if (!part || typeof part !== 'object' || Array.isArray(part)) return []; + const reasoningPart = part as Record; + if (reasoningPart.type === 'reasoning_text' && typeof reasoningPart.text === 'string') { + return [part]; + } + if (reasoningPart.type !== undefined || typeof reasoningPart.text !== 'string') return []; + return [{ ...reasoningPart, type: 'reasoning_text' }]; + }); + return normalized.length ? normalized : undefined; +} + /** * Sanitize a provider request payload for xAI's Responses API via * cli-chat-proxy.grok.com. @@ -245,8 +267,12 @@ export function sanitizePayload( if (!item || typeof item !== 'object') return item; const obj = item as Record; - // Strip replayed reasoning items - if (obj.type === 'reasoning') return null; + if (obj.type === 'reasoning') { + delete obj.status; + const content = normalizeReasoningContent(obj.content); + if (content) obj.content = content; + if (!content) delete obj.content; + } // Drop empty string content if (typeof obj.content === 'string' && obj.content.length === 0) return null; @@ -289,25 +315,42 @@ export function sanitizePayload( delete next.response_format; } - // ── Reasoning effort ────────────────────────────────────────────────── - if (supportsReasoningEffort(modelId)) { - const reasoning = next.reasoning as Record | undefined; - if (reasoning) { - const effort = reasoning.effort === 'minimal' ? 'low' : reasoning.effort; - next.reasoning = reasoning.summary !== undefined ? { effort } : { ...reasoning, effort }; + // ── Reasoning request configuration ─────────────────────────────────── + const reasoning = + next.reasoning && typeof next.reasoning === 'object' && !Array.isArray(next.reasoning) + ? { ...(next.reasoning as Record) } + : undefined; + const reasoningSupported = supportsReasoning(modelId); + delete next.reasoningEffort; + + if (!reasoningSupported || !reasoning) delete next.reasoning; + if (reasoningSupported && reasoning) { + const effortSupported = supportsReasoningEffort(modelId); + if (effortSupported) { + if (reasoning.effort === 'minimal') reasoning.effort = 'low'; + if (reasoning.effort === undefined) delete reasoning.effort; } - } else { - delete next.reasoning; - delete next.reasoningEffort; + if (!effortSupported) delete reasoning.effort; + if (Object.keys(reasoning).length === 0) delete next.reasoning; + if (Object.keys(reasoning).length > 0) next.reasoning = reasoning; } - // ── Strip/filter unsupported fields ────────────────────────────────── + // ── Strip unsupported fields ───────────────────────────────────────── if (Array.isArray(next.include)) { - next.include = (next.include as unknown[]).filter( - (item) => item !== 'reasoning.encrypted_content', - ); - if ((next.include as unknown[]).length === 0) delete next.include; + const hasReasoning = next.reasoning !== undefined; + let keptEncryptedReasoning = false; + const include = next.include.filter((item) => { + if (item !== ENCRYPTED_REASONING_INCLUDE) return true; + if (!reasoningSupported || keptEncryptedReasoning) return false; + keptEncryptedReasoning = true; + return true; + }); + if (hasReasoning && !keptEncryptedReasoning) include.push(ENCRYPTED_REASONING_INCLUDE); + next.include = include; + } else if (next.reasoning !== undefined) { + next.include = [ENCRYPTED_REASONING_INCLUDE]; } + if (Array.isArray(next.include) && next.include.length === 0) delete next.include; delete next.prompt_cache_retention; diff --git a/src/provider/accounts.ts b/src/provider/accounts.ts new file mode 100644 index 0000000..f5e984f --- /dev/null +++ b/src/provider/accounts.ts @@ -0,0 +1,835 @@ +import type { AuthInteraction } from '@earendil-works/pi-ai'; +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, + ModelRuntime, +} from '@earendil-works/pi-coding-agent'; +import { matchesKey, SelectList } from '@earendil-works/pi-tui'; +import { + findAvailableAccountNumber, + type GrokCliAccount, + type GrokCliConfig, + hasTerminalControlCharacters, + loadConfig, + saveConfig, +} from '../config.js'; +import { fetchBillingUsage } from './billing.js'; +import { createAccountDashboard } from './dashboard/server.js'; +import { + formatCachedQuota, + isCachedQuotaFresh, + loadQuotaCache, + removeQuotaUsage, + saveQuotaUsage, +} from './quotaCache.js'; + +export const GROK_CLI_PROVIDER = 'grok-cli'; +export const DEFAULT_GROK_MODEL = 'grok-build'; + +type RegisterAccount = (account: GrokCliAccount) => void; + +export type PlanTier = 'free' | 'supergrok-lite' | 'supergrok' | 'supergrok-heavy'; + +export interface AccountSnapshot { + provider: string; + label: string; + status: string; + authenticated: boolean; + active: boolean; + environment: boolean; + plan?: PlanTier; + quota?: { + updatedAt: string; + fresh: boolean; + monthly: { + monthlyLimit: number; + used: number; + billingPeriodEnd: string; + }; + weekly?: { + creditUsagePercent: number; + billingPeriodEnd: string; + }; + }; +} + +export interface AccountsSnapshot { + accounts: AccountSnapshot[]; +} + +export function isGrokCliProvider(provider: string | undefined): boolean { + return provider === GROK_CLI_PROVIDER || /^grok-cli-(?:[2-9]|[1-9]\d+)$/.test(provider ?? ''); +} + +function copyConfig(): GrokCliConfig { + const config = loadConfig().config; + return { + ...config, + accounts: { + ...config.accounts, + items: config.accounts.items.map((account) => ({ ...account })), + }, + imagine: { ...config.imagine }, + vision: { ...config.vision }, + }; +} + +function accountNumber(provider: string) { + if (provider === GROK_CLI_PROVIDER) return 1; + return Number(provider.slice('grok-cli-'.length)); +} + +function defaultLabel(provider: string) { + return `Account ${accountNumber(provider)}`; +} + +function labelError(config: GrokCliConfig, provider: string, label: string) { + if ([...label].length > 40) return 'Account labels must be 40 characters or fewer.'; + if (hasTerminalControlCharacters(label)) + return 'Account labels cannot contain control characters.'; + if ( + config.accounts.items.some( + (account) => + account.provider !== provider && + account.label.toLocaleLowerCase() === label.toLocaleLowerCase(), + ) + ) { + return `An account named “${label}” already exists.`; + } + return undefined; +} + +function normalizeLabel(config: GrokCliConfig, provider: string, value: string) { + const label = value.trim() || defaultLabel(provider); + const error = labelError(config, provider, label); + if (error) throw new Error(error); + return label; +} + +async function promptLabel( + ctx: ExtensionCommandContext, + config: GrokCliConfig, + provider: string, + title: string, +) { + while (true) { + const input = await ctx.ui.input(title, defaultLabel(provider)); + if (input === undefined) return undefined; + const label = input.trim() || defaultLabel(provider); + const error = labelError(config, provider, label); + if (!error) return label; + ctx.ui.notify(error, 'error'); + } +} + +function hasStoredAuth(ctx: ExtensionContext, provider: string) { + return ctx.modelRegistry.getProviderAuthStatus(provider).configured; +} + +function accountRuntime(ctx: ExtensionContext) { + const runtime = (ctx.modelRegistry as unknown as { runtime?: ModelRuntime }).runtime; + if (!runtime) throw new Error('Pi account authentication runtime is unavailable.'); + return runtime; +} + +function hasAccountAuth(ctx: ExtensionContext, provider: string) { + return ( + hasStoredAuth(ctx, provider) || + (provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN)) + ); +} + +function accountStatus(ctx: ExtensionContext, config: GrokCliConfig, provider: string) { + const environment = provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN); + if (config.accounts.selectedProvider === provider && hasAccountAuth(ctx, provider)) { + return environment ? 'Active (environment)' : 'Active'; + } + if (environment) return 'Authenticated (environment)'; + return hasStoredAuth(ctx, provider) ? 'Authenticated' : 'Login required'; +} + +// Tier from the monthly credit cap: 0 free, up to 4,000 Lite, up to 20,000 SuperGrok, above Heavy. +export function planTier(monthlyLimit: number): PlanTier { + if (monthlyLimit <= 0) return 'free'; + if (monthlyLimit <= 4000) return 'supergrok-lite'; + if (monthlyLimit <= 20000) return 'supergrok'; + return 'supergrok-heavy'; +} + +async function resolveAccountToken(ctx: ExtensionContext, provider: string) { + if (provider === GROK_CLI_PROVIDER && process.env.GROK_CLI_OAUTH_TOKEN) { + return process.env.GROK_CLI_OAUTH_TOKEN; + } + try { + return await ctx.modelRegistry.getApiKeyForProvider(provider); + } catch { + return undefined; + } +} + +async function refreshAccountBatches( + accounts: T[], + refresh: (account: T) => Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) return; + const batch = accounts.slice(0, 3); + if (!batch.length) return; + await Promise.all(batch.map(refresh)); + await refreshAccountBatches(accounts.slice(3), refresh, signal); +} + +function showAccountSelector( + ctx: ExtensionCommandContext, + config: GrokCliConfig, + mode: 'main' | 'manage', + manager: AccountManager, +) { + return ctx.ui.custom((tui, theme, _keybindings, done) => { + const accountItems = new Map( + config.accounts.items.map((account) => [ + account.provider, + { + value: account.provider, + label: `${account.label} — ${accountStatus(ctx, config, account.provider)}`, + description: '', + }, + ]), + ); + const items = [ + ...accountItems.values(), + ...(mode === 'main' + ? [ + { value: 'action:add', label: '+ Add account', description: '' }, + { value: 'action:manage', label: 'Manage accounts', description: '' }, + ] + : []), + ]; + const selectList = new SelectList( + items, + Math.min(items.length, 10), + { + selectedPrefix: (text) => theme.fg('accent', text), + selectedText: (text) => theme.fg('accent', text), + description: (text) => theme.fg('dim', text), + scrollInfo: (text) => theme.fg('dim', text), + noMatch: (text) => theme.fg('warning', text), + }, + { minPrimaryColumnWidth: 24, maxPrimaryColumnWidth: 40 }, + ); + const refreshingProviders = new Set(); + const failedProviders = new Set(); + const controller = new AbortController(); + let cache = loadQuotaCache(); + let status: string | undefined; + let refreshing = false; + let disposed = false; + + const updateDescriptions = () => { + for (const account of config.accounts.items) { + const item = accountItems.get(account.provider); + if (!item) continue; + const cached = cache.accounts[account.provider]; + item.description = !hasAccountAuth(ctx, account.provider) + ? 'Quota unavailable · login required' + : refreshingProviders.has(account.provider) + ? 'Refreshing…' + : failedProviders.has(account.provider) + ? cached + ? `${formatCachedQuota(cached)} · refresh failed` + : 'Refresh failed · press r to retry' + : cached + ? formatCachedQuota(cached) + : 'Quota not fetched · press r'; + } + selectList.invalidate(); + }; + + const close = (value: string | undefined) => { + if (disposed) return; + disposed = true; + controller.abort(); + done(value); + }; + + const startRefresh = async () => { + if (refreshing || disposed) return; + const accounts = config.accounts.items.filter((account) => + hasAccountAuth(ctx, account.provider), + ); + if (!accounts.length) { + status = 'No logged-in accounts to refresh'; + tui.requestRender(); + return; + } + refreshing = true; + failedProviders.clear(); + for (const account of accounts) refreshingProviders.add(account.provider); + status = `Refreshing quotas… 0/${accounts.length}`; + updateDescriptions(); + tui.requestRender(); + const result = await manager.refresh(ctx, controller.signal, (progress) => { + refreshingProviders.delete(progress.provider); + if (!progress.updated) failedProviders.add(progress.provider); + cache = loadQuotaCache(); + status = `Refreshing quotas… ${progress.completed}/${progress.total}`; + updateDescriptions(); + if (!disposed) tui.requestRender(); + }); + + refreshing = false; + if (disposed) return; + status = `Updated ${result.updated} accounts; ${result.failed.length} failed`; + tui.requestRender(); + }; + + selectList.onSelect = (item) => close(item.value); + selectList.onCancel = () => close(undefined); + updateDescriptions(); + + return { + render(width: number) { + return [ + theme.fg( + 'accent', + theme.bold(mode === 'main' ? 'Grok CLI accounts:' : 'Manage Grok CLI account:'), + ), + '', + ...selectList.render(width), + '', + ...(status ? [theme.fg('dim', status)] : []), + theme.fg( + 'dim', + `r refresh quotas · enter select · esc ${mode === 'main' ? 'close' : 'back'}`, + ), + ]; + }, + invalidate() { + selectList.invalidate(); + }, + handleInput(data: string) { + if (data === 'r' || data === 'R' || matchesKey(data, 'r')) { + void startRefresh(); + return; + } + selectList.handleInput(data); + if (!disposed) tui.requestRender(); + }, + dispose() { + if (disposed) return; + disposed = true; + controller.abort(); + }, + }; + }); +} + +function prefillLogin(ctx: ExtensionContext, provider: string) { + ctx.ui.setEditorText(`/login ${provider}`); +} + +async function addAccount( + ctx: ExtensionCommandContext, + config: GrokCliConfig, + manager: AccountManager, +) { + const provider = manager.nextProvider(); + const label = await promptLabel(ctx, config, provider, 'Label this Grok CLI account:'); + if (!label) return; + try { + prefillLogin(ctx, (await manager.add(ctx, label)).provider); + } catch (error) { + ctx.ui.notify( + `Could not add Grok CLI account: ${error instanceof Error ? error.message : String(error)}`, + 'error', + ); + } +} + +async function renameAccount( + ctx: ExtensionCommandContext, + config: GrokCliConfig, + account: GrokCliAccount, + manager: AccountManager, +) { + const label = await promptLabel(ctx, config, account.provider, `Rename “${account.label}”:`); + if (!label) return; + try { + await manager.rename(ctx, account.provider, label); + } catch (error) { + ctx.ui.notify( + `Could not rename Grok CLI account: ${error instanceof Error ? error.message : String(error)}`, + 'error', + ); + } +} + +async function removeBaseAccount( + ctx: ExtensionCommandContext, + account: GrokCliAccount, + manager: AccountManager, +) { + if ( + !(await ctx.ui.confirm( + `Log out of “${account.label}”?`, + 'This removes its saved OAuth login from Pi.', + )) + ) { + return; + } + try { + const result = await manager.logout(ctx, account.provider); + if (result.warning) ctx.ui.notify(result.warning, 'warning'); + } catch (error) { + ctx.ui.notify( + `Could not log out of Grok CLI: ${error instanceof Error ? error.message : String(error)}`, + 'error', + ); + } +} + +async function fallbackBeforeRemoval( + pi: ExtensionAPI, + ctx: ExtensionContext, + config: GrokCliConfig, + removed: GrokCliAccount, +) { + const candidates = config.accounts.items.filter( + (account) => account.provider !== removed.provider && hasAccountAuth(ctx, account.provider), + ); + if (ctx.model?.provider !== removed.provider) { + return config.accounts.selectedProvider === removed.provider + ? (candidates[0]?.provider ?? GROK_CLI_PROVIDER) + : config.accounts.selectedProvider; + } + for (const account of candidates) { + const target = + ctx.modelRegistry.find(account.provider, ctx.model?.id ?? DEFAULT_GROK_MODEL) ?? + ctx.modelRegistry.find(account.provider, DEFAULT_GROK_MODEL); + if (target && (await pi.setModel(target))) return account.provider; + } + return candidates.length ? null : undefined; +} + +async function removeAlias( + ctx: ExtensionCommandContext, + account: GrokCliAccount, + manager: AccountManager, +) { + if ( + !(await ctx.ui.confirm( + `Remove “${account.label}”?`, + 'This removes its saved OAuth login from Pi and deletes the account slot.', + )) + ) { + return; + } + try { + const result = await manager.remove(ctx, account.provider); + if (result.warning) ctx.ui.notify(result.warning, 'warning'); + } catch (error) { + ctx.ui.notify( + `Could not remove Grok CLI account: ${error instanceof Error ? error.message : String(error)}`, + 'error', + ); + } +} + +async function manageAccount( + ctx: ExtensionCommandContext, + config: GrokCliConfig, + account: GrokCliAccount, + manager: AccountManager, +) { + const environment = + account.provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN); + const loginLabel = hasStoredAuth(ctx, account.provider) ? 'Log in again' : 'Log in'; + const removeLabel = account.provider === GROK_CLI_PROVIDER ? 'Log out' : 'Log out and remove'; + const options = environment + ? ['Rename', 'Environment token instructions', 'Back'] + : ['Rename', loginLabel, removeLabel, 'Back']; + const action = await ctx.ui.select(`Manage “${account.label}”:`, options); + switch (action) { + case 'Rename': + await renameAccount(ctx, config, account, manager); + return; + case loginLabel: + prefillLogin(ctx, account.provider); + return; + case 'Environment token instructions': + ctx.ui.notify( + 'Unset GROK_CLI_OAUTH_TOKEN and restart Pi to remove the environment token.', + 'info', + ); + return; + case 'Log out': + await removeBaseAccount(ctx, account, manager); + return; + case 'Log out and remove': + await removeAlias(ctx, account, manager); + } +} + +export function resolveGrokProvider(ctx: Pick) { + const config = loadConfig().config; + if ( + isGrokCliProvider(ctx.model?.provider) && + config.accounts.items.some((account) => account.provider === ctx.model?.provider) + ) { + return ctx.model?.provider ?? GROK_CLI_PROVIDER; + } + return config.accounts.selectedProvider; +} + +export async function resolveGrokToken( + ctx: Pick, +): Promise { + const provider = resolveGrokProvider(ctx); + if (provider === GROK_CLI_PROVIDER && process.env.GROK_CLI_OAUTH_TOKEN) { + return process.env.GROK_CLI_OAUTH_TOKEN; + } + try { + return await ctx.modelRegistry.getApiKeyForProvider(provider); + } catch { + return undefined; + } +} + +function createAccountManager( + pi: ExtensionAPI, + registerAccount: RegisterAccount, + deferredRemovals: Set, +) { + let mutations = Promise.resolve(); + const accountGenerations = new Map(); + const mutate = (operation: () => Promise | T) => { + const result = mutations.then(operation, operation); + mutations = result.then( + () => undefined, + () => undefined, + ); + return result; + }; + const accountGeneration = (provider: string) => accountGenerations.get(provider) ?? 0; + const invalidateAccount = (provider: string) => + accountGenerations.set(provider, accountGeneration(provider) + 1); + const accountFrom = (config: GrokCliConfig, provider: string) => { + const account = config.accounts.items.find((candidate) => candidate.provider === provider); + if (!account) throw new Error(`Unknown Grok CLI account: ${provider}`); + return account; + }; + const clearQuota = async (provider: string) => { + try { + await removeQuotaUsage(provider); + return undefined; + } catch (error) { + return `Grok CLI quota cache cleanup failed: ${error instanceof Error ? error.message : String(error)}`; + } + }; + const refreshAccounts = async ( + ctx: ExtensionContext, + accounts: { account: GrokCliAccount; generation: number }[], + signal: AbortSignal, + onProgress?: (progress: { + provider: string; + completed: number; + total: number; + updated: boolean; + }) => void, + ) => { + let completed = 0; + let updated = 0; + const failed: string[] = []; + await refreshAccountBatches( + accounts, + async ({ account, generation }) => { + let succeeded = false; + try { + const token = await resolveAccountToken(ctx, account.provider); + if (!token) throw new Error('authentication unavailable'); + const usage = await fetchBillingUsage( + token, + AbortSignal.any([signal, AbortSignal.timeout(30_000)]), + ); + succeeded = await mutate(async () => { + if ( + accountGeneration(account.provider) !== generation || + !copyConfig().accounts.items.some( + (candidate) => candidate.provider === account.provider, + ) || + !hasAccountAuth(ctx, account.provider) + ) { + return false; + } + await saveQuotaUsage(account.provider, usage); + return true; + }); + if (succeeded) updated += 1; + } catch { + if (!signal.aborted) failed.push(account.provider); + } finally { + completed += 1; + onProgress?.({ + provider: account.provider, + completed, + total: accounts.length, + updated: succeeded, + }); + } + }, + signal, + ); + return { updated, failed }; + }; + + return { + nextProvider() { + return `${GROK_CLI_PROVIDER}-${findAvailableAccountNumber( + copyConfig().accounts.items.map((account) => account.provider), + deferredRemovals, + )}`; + }, + add(_ctx: ExtensionContext, value: string) { + return mutate(() => { + const config = copyConfig(); + const provider = `${GROK_CLI_PROVIDER}-${findAvailableAccountNumber( + config.accounts.items.map((account) => account.provider), + deferredRemovals, + )}`; + const account = { provider, label: normalizeLabel(config, provider, value) }; + config.accounts.items.push(account); + config.accounts.nextAccountNumber = findAvailableAccountNumber( + config.accounts.items.map((candidate) => candidate.provider), + ); + saveConfig(config); + invalidateAccount(provider); + registerAccount(account); + return account; + }); + }, + rename(_ctx: ExtensionContext, provider: string, value: string) { + return mutate(() => { + const config = copyConfig(); + const account = accountFrom(config, provider); + account.label = normalizeLabel(config, provider, value); + saveConfig(config); + registerAccount(account); + return account; + }); + }, + activate(ctx: ExtensionContext, provider: string) { + return mutate(async () => { + const config = copyConfig(); + const account = accountFrom(config, provider); + if (!hasAccountAuth(ctx, provider)) { + throw new Error(`Log in to “${account.label}” before making it active.`); + } + const modelId = isGrokCliProvider(ctx.model?.provider) ? ctx.model?.id : DEFAULT_GROK_MODEL; + const model = + ctx.modelRegistry.find(provider, modelId ?? DEFAULT_GROK_MODEL) ?? + ctx.modelRegistry.find(provider, DEFAULT_GROK_MODEL); + if (!model) throw new Error(`Grok CLI model unavailable for “${account.label}”.`); + if (!(await pi.setModel(model))) { + throw new Error(`Could not switch to “${account.label}”; authentication is unavailable.`); + } + config.accounts.selectedProvider = provider; + saveConfig(config); + return account; + }); + }, + async login(ctx: ExtensionContext, provider: string, interaction: AuthInteraction) { + const config = copyConfig(); + const account = accountFrom(config, provider); + if (provider === GROK_CLI_PROVIDER && process.env.GROK_CLI_OAUTH_TOKEN) { + throw new Error('Unset GROK_CLI_OAUTH_TOKEN before logging in from the dashboard.'); + } + const result = await accountRuntime(ctx).login(account.provider, 'oauth', interaction); + invalidateAccount(provider); + return result; + }, + logout(ctx: ExtensionContext, provider: string) { + return mutate(async () => { + if (provider !== GROK_CLI_PROVIDER) { + throw new Error('Only the permanent base account can be logged out without removal.'); + } + if (process.env.GROK_CLI_OAUTH_TOKEN) { + throw new Error( + 'Unset GROK_CLI_OAUTH_TOKEN and restart Pi to remove the environment token.', + ); + } + const config = copyConfig(); + const account = accountFrom(config, provider); + await accountRuntime(ctx).logout(provider); + invalidateAccount(provider); + account.label = 'Account 1'; + saveConfig(config); + registerAccount(account); + return { warning: await clearQuota(provider) }; + }); + }, + remove(ctx: ExtensionContext, provider: string) { + return mutate(async () => { + if (provider === GROK_CLI_PROVIDER) { + throw new Error('The permanent base account cannot be removed.'); + } + const config = copyConfig(); + const account = accountFrom(config, provider); + const fallback = await fallbackBeforeRemoval(pi, ctx, config, account); + if (fallback === null) { + throw new Error('Could not switch to another authenticated Grok CLI account.'); + } + await accountRuntime(ctx).logout(provider); + invalidateAccount(provider); + config.accounts.items = config.accounts.items.filter( + (candidate) => candidate.provider !== provider, + ); + config.accounts.selectedProvider = fallback ?? GROK_CLI_PROVIDER; + saveConfig(config); + const cacheWarning = await clearQuota(provider); + if (ctx.model?.provider === provider && fallback === undefined) { + deferredRemovals.add(provider); + return { + warning: + cacheWarning ?? + 'Grok CLI account removed. The current model now requires login and will disappear after you switch models.', + }; + } + pi.unregisterProvider(provider); + return { warning: cacheWarning }; + }); + }, + async refresh( + ctx: ExtensionContext, + signal: AbortSignal, + onProgress?: (progress: { + provider: string; + completed: number; + total: number; + updated: boolean; + }) => void, + ) { + return refreshAccounts( + ctx, + copyConfig().accounts.items.flatMap((account) => + hasAccountAuth(ctx, account.provider) + ? [{ account, generation: accountGeneration(account.provider) }] + : [], + ), + signal, + onProgress, + ); + }, + async refreshOne(ctx: ExtensionContext, provider: string, signal: AbortSignal) { + const account = accountFrom(copyConfig(), provider); + if (!hasAccountAuth(ctx, provider)) { + throw new Error(`Log in to “${account.label}” before refreshing its quota.`); + } + return refreshAccounts(ctx, [{ account, generation: accountGeneration(provider) }], signal); + }, + snapshot(ctx: ExtensionContext): AccountsSnapshot { + const config = copyConfig(); + const cache = loadQuotaCache(); + return { + accounts: config.accounts.items.map((account) => { + const environment = + account.provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN); + const authenticated = hasAccountAuth(ctx, account.provider); + const quota = cache.accounts[account.provider]; + return { + provider: account.provider, + label: account.label, + status: accountStatus(ctx, config, account.provider), + authenticated, + active: config.accounts.selectedProvider === account.provider && authenticated, + environment, + ...(quota + ? { + plan: planTier(quota.monthly.monthlyLimit), + quota: { + updatedAt: quota.updatedAt, + fresh: isCachedQuotaFresh(quota), + monthly: { ...quota.monthly }, + ...(quota.weekly ? { weekly: { ...quota.weekly } } : {}), + }, + } + : {}), + }; + }), + }; + }, + handleModelSelect(event: { + model: { provider: string }; + previousModel?: { provider: string }; + }) { + if (event.previousModel && deferredRemovals.delete(event.previousModel.provider)) { + pi.unregisterProvider(event.previousModel.provider); + } + if (!isGrokCliProvider(event.model.provider)) return; + return mutate(() => { + const config = copyConfig(); + if (!config.accounts.items.some((account) => account.provider === event.model.provider)) + return; + if (config.accounts.selectedProvider === event.model.provider) return; + config.accounts.selectedProvider = event.model.provider; + saveConfig(config); + }); + }, + }; +} + +export type AccountManager = ReturnType; + +export function registerAccountManagement(pi: ExtensionAPI, registerAccount: RegisterAccount) { + const deferredRemovals = new Set(); + const manager = createAccountManager(pi, registerAccount, deferredRemovals); + const dashboard = createAccountDashboard(manager); + + pi.registerCommand('grok-cli-accounts', { + description: + 'Add, switch, rename, relogin, or remove Grok CLI accounts; pass gui to open the browser UI', + handler: async (args, ctx) => { + const command = args.trim(); + if (command === 'gui') { + await dashboard.open(ctx); + return; + } + if (command) { + ctx.ui.notify('Usage: /grok-cli-accounts [gui]', 'warning'); + return; + } + const config = copyConfig(); + const choice = await showAccountSelector(ctx, config, 'main', manager); + if (choice === 'action:add') { + await addAccount(ctx, config, manager); + return; + } + if (choice === 'action:manage') { + const latest = copyConfig(); + const selected = await showAccountSelector(ctx, latest, 'manage', manager); + const account = latest.accounts.items.find(({ provider }) => provider === selected); + if (account) { + await manageAccount(ctx, latest, account, manager); + } + return; + } + const account = config.accounts.items.find(({ provider }) => provider === choice); + if (!account) return; + if (!hasAccountAuth(ctx, account.provider)) { + prefillLogin(ctx, account.provider); + return; + } + try { + await manager.activate(ctx, account.provider); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error'); + } + }, + }); + + return { + manager, + closeDashboard: dashboard.close, + handleModelSelect: manager.handleModelSelect, + }; +} diff --git a/src/provider/billing.ts b/src/provider/billing.ts index 0c5e3fb..19accf0 100644 --- a/src/provider/billing.ts +++ b/src/provider/billing.ts @@ -1,17 +1,17 @@ import { getBaseUrl } from '../auth/oauth.js'; -interface MonthlyUsage { +export interface MonthlyUsage { monthlyLimit: number; used: number; billingPeriodEnd: string; } -interface WeeklyUsage { +export interface WeeklyUsage { creditUsagePercent: number; billingPeriodEnd: string; } -interface BillingUsage { +export interface BillingUsage { monthly: MonthlyUsage; weekly?: WeeklyUsage; } @@ -75,18 +75,27 @@ function parseWeeklyUsage(payload: unknown): WeeklyUsage | undefined { return { creditUsagePercent, billingPeriodEnd }; } -export async function fetchBillingUsage(token: string): Promise { +export async function fetchBillingUsage( + token: string, + signal?: AbortSignal, +): Promise { const headers = billingHeaders(token); - const monthlyResponse = await fetch(`${getBaseUrl()}/billing`, { headers }); + const monthlyResponse = await fetch(`${getBaseUrl()}/billing`, { headers, signal }); if (!monthlyResponse.ok) throw new Error(`billing endpoint returned ${monthlyResponse.status}`); const monthly = parseMonthlyUsage(await monthlyResponse.json()); - const weekly = await fetchWeeklyUsage(headers).catch(() => undefined); + const weekly = await fetchWeeklyUsage(headers, signal).catch((error: unknown) => { + if (signal?.aborted) throw error; + return undefined; + }); return { monthly, weekly }; } -async function fetchWeeklyUsage(headers: Record): Promise { - const response = await fetch(`${getBaseUrl()}/billing?format=credits`, { headers }); +async function fetchWeeklyUsage( + headers: Record, + signal?: AbortSignal, +): Promise { + const response = await fetch(`${getBaseUrl()}/billing?format=credits`, { headers, signal }); if (!response.ok) return undefined; return parseWeeklyUsage(await response.json()); } diff --git a/src/provider/dashboard/app.css b/src/provider/dashboard/app.css new file mode 100644 index 0000000..abd94ed --- /dev/null +++ b/src/provider/dashboard/app.css @@ -0,0 +1,997 @@ +:root { + color-scheme: dark; + --bg: oklch(0.14 0.005 260); + --panel: oklch(0.185 0.007 260); + --panel-raised: oklch(0.225 0.008 260); + --panel-veil: oklch(0.185 0.007 260 / 84%); + --gauge-disc: oklch(0.19 0.007 260 / 94%); + --border: rgb(255 255 255 / 8%); + --border-strong: rgb(255 255 255 / 14%); + --text: oklch(0.96 0.004 250); + --muted: oklch(0.68 0.015 260); + --dim: oklch(0.62 0.015 260); + --accent: oklch(0.55 0.16 275); + --accent-ring: oklch(0.55 0.16 275 / 45%); + --accent-soft: oklch(0.55 0.16 275 / 14%); + --accent-text: oklch(0.8 0.1 275); + --ok: oklch(0.72 0.14 160); + --ok-soft: oklch(0.72 0.14 160 / 14%); + /* Text-on-tint variants: hold 4.5:1 on their soft fills. */ + --ok-text: oklch(0.8 0.13 160); + --amber: oklch(0.84 0.14 90); + --amber-soft: oklch(0.84 0.14 90 / 13%); + --red: oklch(0.65 0.19 25); + --red-soft: oklch(0.65 0.19 25 / 10%); + --red-text: oklch(0.72 0.17 25); + --track: oklch(0.26 0.008 260); + --appbar-bg: oklch(0.14 0.005 260 / 80%); + --input-bg: oklch(0.165 0.006 260); + --danger-ink: oklch(0.2 0.04 25); + /* Entrance and overlay motion: exponential ease-out, no overshoot. */ + --ease: cubic-bezier(0.16, 1, 0.3, 1); + --ui: + "Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", + sans-serif; + --mono: ui-monospace, "SF Mono", "Cascadia Mono", "Menlo", "Consolas", monospace; + + background: var(--bg); + color: var(--text); + font-family: var(--ui); + font-size: 14px; +} + +/* Gauge sweep angle, registered so it interpolates in CSS and WAAPI. */ +@property --gauge { + syntax: ""; + inherits: false; + initial-value: 0; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--bg); +} + +body { + min-height: 100vh; + margin: 0; +} + +::selection { + background: oklch(from var(--accent) l c h / 38%); +} + +button, +input { + font: inherit; +} + +button { + border: 0; +} + +button:focus-visible, +input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +/* Doubled selector so the utility beats component display rules (.dialog-panel label, .button). */ +[hidden][hidden] { + display: none; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* ---------- State field backdrop ---------- */ + +#field { + position: fixed; + inset: 0; + z-index: -1; + width: 100%; + height: 100%; + transition: filter 240ms ease; +} + +/* Painted stand-in when WebGL is unavailable: layered aurora, static. */ +.no-field #field { + display: none; +} + +.no-field body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + background: + radial-gradient(65% 55% at 18% 8%, oklch(from var(--accent) l c h / 11%), transparent 70%), + radial-gradient(50% 45% at 85% 85%, oklch(from var(--ok) l c h / 7%), transparent 70%), + var(--bg); +} + +/* Dim the field while a modal owns attention. */ +body:has(dialog[open]) #field { + filter: brightness(0.55); +} + +/* ---------- App bar ---------- */ + +.appbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + flex-wrap: wrap; + gap: 12px 20px; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + border-bottom: 1px solid var(--border); + background: var(--appbar-bg); + backdrop-filter: blur(14px); +} + +.appbar-brand { + display: flex; + min-width: 0; + gap: 10px; + align-items: center; +} + +.brand-mark { + width: 22px; + height: 22px; + flex: none; +} + +.brand-name { + font-size: 13.5px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; +} + +.brand-meta { + overflow: hidden; + color: var(--dim); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.brand-sep { + margin: 0 8px 0 0; + color: var(--border-strong); +} + +.appbar-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.link-pill { + display: inline-flex; + gap: 7px; + align-items: center; + height: 28px; + padding: 0 11px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel-veil); + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.link-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--dim); +} + +.link-pill.ok .link-dot { + background: var(--ok); +} + +.link-pill.pending .link-dot { + background: var(--amber); + animation: pulse 1.6s ease-in-out infinite; +} + +.link-pill.error { + border-color: oklch(from var(--red) l c h / 35%); + color: var(--red); +} + +.link-pill.error .link-dot { + background: var(--red); +} + +input { + min-width: 0; + height: 34px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0 11px; + background: var(--input-bg); + color: var(--text); + font-size: 13px; +} + +input::placeholder { + color: var(--dim); +} + +input:hover { + border-color: var(--border-strong); +} + +/* ---------- Buttons ---------- */ + +.button { + display: inline-flex; + gap: 7px; + align-items: center; + justify-content: center; + height: 34px; + border: 1px solid transparent; + border-radius: 6px; + padding: 0 13px; + cursor: pointer; + font-size: 12.5px; + font-weight: 600; + letter-spacing: 0; + white-space: nowrap; + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease, + scale 120ms ease; +} + +.button:active:not(:disabled) { + scale: 0.98; +} + +.button.primary { + background: var(--text); + color: var(--bg); +} + +.button.primary:hover:not(:disabled) { + background: oklch(from var(--text) calc(l - 0.12) c h); +} + +.button.primary.danger { + background: var(--red); + color: var(--danger-ink); +} + +.button.primary.danger:hover:not(:disabled) { + background: oklch(from var(--red) calc(l + 0.07) c h); + color: var(--danger-ink); +} + +.button.ghost { + border-color: var(--border); + background: transparent; + color: var(--text); +} + +.button.ghost:hover:not(:disabled) { + border-color: var(--border-strong); + background: var(--panel-raised); +} + +.button.danger { + border-color: transparent; + background: transparent; + color: var(--muted); +} + +.button.danger:hover:not(:disabled) { + background: var(--red-soft); + color: var(--red-text); +} + +.button.small { + height: 30px; + padding: 0 10px; + font-size: 12px; +} + +.link-button { + padding: 0; + border: 0; + background: none; + color: var(--muted); + cursor: pointer; + font-size: 12px; + text-align: left; + text-decoration: underline; + text-decoration-color: var(--border-strong); + text-underline-offset: 3px; +} + +.link-button:hover:not(:disabled) { + color: var(--red-text); + text-decoration-color: var(--red); +} + +/* Muted links fall below 4.5:1 on the amber login tint. */ +.login-panel .link-button { + color: var(--text); +} + +/* ---------- Account grid ---------- */ + +main { + width: min(1200px, 100%); + margin: 0 auto; + padding: 20px 20px 72px; + transition: filter 300ms ease; +} + +/* Offline: the whole board desaturates until the link recovers. Saturation only — + dimming with brightness() would pull dim text below AA contrast. */ +body:has(.link-pill.error) main { + filter: saturate(0.55); +} + +.account-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 370px), 1fr)); + gap: 14px; +} + +.grid-message { + grid-column: 1 / -1; + padding: 56px 24px; + border: 1px dashed var(--border-strong); + border-radius: 10px; + background: var(--panel-veil); + color: var(--muted); + font-size: 13px; + line-height: 1.6; + text-align: center; +} + +/* ---------- Account card ---------- */ + +.account-card { + container-type: inline-size; + position: relative; + display: flex; + flex-direction: column; + gap: 14px; + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + background: var(--panel-veil); + backdrop-filter: blur(12px) saturate(1.1); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 3%); +} + +@supports (corner-shape: squircle) { + .account-card { + border-radius: 22px; + corner-shape: squircle; + } +} + +/* Entrance runs on first mount only; poll-driven re-renders stay still. */ +.account-grid:not(.settled) .account-card { + animation: card-enter 620ms var(--ease) both; + animation-delay: var(--enter-delay, 0ms); +} + +/* Cursor spotlight: a 1px ring masked to the border band, lit near the pointer. */ +.account-card::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + background: radial-gradient( + 200px circle at var(--mx, 50%) var(--my, 50%), + rgb(255 255 255 / 40%), + rgb(255 255 255 / 12%) 55%, + transparent 75% + ); + opacity: var(--glow, 0); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; + pointer-events: none; + transition: opacity 180ms ease; +} + +.account-card.active { + border-color: var(--accent-ring); + background: + linear-gradient(160deg, oklch(from var(--accent) l c h / 7%), transparent 45%), + var(--panel-veil); +} + +.account-card:has(.login-panel) { + border-color: oklch(from var(--amber) l c h / 32%); +} + +.card-head { + display: grid; + gap: 7px; +} + +.card-title-row { + display: flex; + min-width: 0; + gap: 10px; + align-items: center; + justify-content: space-between; +} + +.card-title-row h2 { + overflow: hidden; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-meta-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.card-provider { + flex: none; + border: 1px solid var(--border); + border-radius: 5px; + padding: 2px 6px; + background: rgb(255 255 255 / 3%); + color: var(--dim); + font-family: var(--mono); + font-size: 11px; +} + +.active-badge { + padding: 3px 8px; + border-radius: 999px; + background: var(--ok-soft); + color: var(--ok-text); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.status-pill { + display: inline-flex; + gap: 6px; + align-items: center; + padding: 3px 9px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel-raised); + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + +.status-pill span:last-child { + overflow: hidden; + text-overflow: ellipsis; +} + +.plan-pill { + flex: none; + padding: 3px 9px; + border: 1px solid var(--accent-ring); + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent-text); + font-size: 11px; + white-space: nowrap; +} + +.status-dot { + width: 6px; + height: 6px; + flex: none; + border-radius: 50%; + background: var(--dim); +} + +.status-pill.ok .status-dot { + background: var(--ok); +} + +.status-pill.pending { + border-color: oklch(from var(--amber) l c h / 30%); + color: var(--amber); +} + +.status-pill.pending .status-dot { + background: var(--amber); + animation: pulse 1.6s ease-in-out infinite; +} + +.status-pill.error { + border-color: oklch(from var(--red) l c h / 30%); + color: var(--red); +} + +.status-pill.error .status-dot { + background: var(--red); +} + +/* ---------- Card body: quota gauges ---------- */ + +.card-body { + display: grid; + gap: 13px; +} + +.quota { + display: flex; + gap: 12px; + align-items: center; +} + +.quota-gauge { + --gauge: 0; + --gauge-color: var(--ok); + position: relative; + flex: none; + width: 54px; + aspect-ratio: 1; + display: grid; + place-items: center; + border-radius: 50%; + background: conic-gradient( + from -90deg, + var(--gauge-color) calc(var(--gauge) * 1%), + var(--track) 0 + ); + transition: filter 200ms ease; +} + +.quota-gauge::after { + content: ""; + position: absolute; + inset: 5px; + border-radius: 50%; + background: var(--gauge-disc); +} + +.quota-gauge.warning { + --gauge-color: var(--amber); +} + +.quota-gauge.danger { + --gauge-color: var(--red); + filter: drop-shadow(0 0 7px oklch(from var(--red) l c h / 35%)); +} + +.account-grid.refreshing .quota-gauge { + animation: pulse 1.6s ease-in-out infinite; +} + +.gauge-value { + position: relative; + z-index: 1; + color: var(--text); + font-family: var(--mono); + font-size: 10.5px; + letter-spacing: -0.02em; +} + +.quota-side { + display: grid; + min-width: 0; + flex: 1; + gap: 4px; +} + +.quota-head, +.quota-meta { + display: flex; + gap: 10px; + align-items: baseline; + justify-content: space-between; +} + +.quota-head { + font-size: 12px; +} + +.quota-head > span:first-child { + color: var(--muted); +} + +.quota-head .mono { + overflow: hidden; + color: var(--dim); + font-family: var(--mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quota-meta { + color: var(--dim); + font-size: 11.5px; +} + +.quota-unavailable { + flex: 1; + color: var(--dim); + font-size: 11.5px; +} + +.card-empty { + padding: 18px 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + color: var(--dim); + font-size: 12.5px; + line-height: 1.55; +} + +.freshness { + display: flex; + gap: 8px; + align-items: center; + color: var(--dim); + font-size: 11.5px; +} + +.tag { + padding: 2px 7px; + border-radius: 999px; + background: var(--amber-soft); + color: var(--amber); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.card-error { + margin: 0; + border: 1px solid oklch(from var(--red) l c h / 28%); + border-radius: 6px; + padding: 9px 11px; + background: var(--red-soft); + color: var(--red-text); + font-size: 12px; + line-height: 1.5; +} + +/* ---------- Card actions ---------- */ + +.card-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + align-items: center; + margin-top: auto; + padding-top: 13px; + border-top: 1px solid var(--border); +} + +.card-actions .push-right { + margin-left: auto; +} + +@container (max-width: 340px) { + .quota-gauge { + width: 46px; + } + + .quota-head { + flex-direction: column; + gap: 1px; + align-items: flex-start; + } + + .card-actions .push-right { + margin-left: 0; + } +} + +/* ---------- Login panel ---------- */ + +.login-panel { + display: grid; + gap: 10px; + border: 1px solid oklch(from var(--amber) l c h / 25%); + border-radius: 6px; + padding: 12px; + background: oklch(from var(--amber) l c h / 6%); +} + +.login-panel p { + color: var(--amber); + font-size: 12px; + line-height: 1.5; +} + +.login-row { + display: flex; + gap: 8px; +} + +.login-row input { + flex: 1; + font-family: var(--mono); + font-size: 12px; +} + +/* ---------- Dialog ---------- */ + +dialog { + width: min(440px, calc(100% - 32px)); + border: 1px solid var(--border-strong); + border-radius: 12px; + padding: 0; + background: var(--panel-raised); + color: var(--text); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 5%), + 0 24px 64px rgb(0 0 0 / 55%); + opacity: 1; + transform: none; + transition: + opacity 180ms ease, + transform 300ms var(--ease), + overlay 180ms ease allow-discrete, + display 180ms ease allow-discrete; +} + +@supports (corner-shape: squircle) { + dialog { + border-radius: 20px; + corner-shape: squircle; + } +} + +dialog:not([open]) { + opacity: 0; + transform: translateY(12px) scale(0.96); +} + +@starting-style { + dialog[open] { + opacity: 0; + transform: translateY(12px) scale(0.96); + } +} + +dialog::backdrop { + background: oklch(0.08 0.005 260 / 72%); + backdrop-filter: blur(6px); + opacity: 0; + transition: + opacity 180ms ease, + overlay 180ms ease allow-discrete, + display 180ms ease allow-discrete; +} + +dialog[open]::backdrop { + opacity: 1; +} + +@starting-style { + dialog[open]::backdrop { + opacity: 0; + } +} + +.dialog-panel { + padding: 24px; +} + +.dialog-panel h2 { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; + text-wrap: balance; +} + +.dialog-message { + margin: 10px 0 20px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; + text-wrap: pretty; +} + +.dialog-panel label { + display: block; + margin-bottom: 7px; + color: var(--muted); + font-size: 12px; +} + +.dialog-panel input { + width: 100%; +} + +.dialog-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 22px; +} + +/* ---------- Toast ---------- */ + +.toast { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 20; + width: min(400px, calc(100% - 36px)); + border: 1px solid var(--border-strong); + border-radius: 10px; + padding: 13px 15px; + background: var(--panel-raised); + color: var(--text); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 5%), + 0 16px 40px rgb(0 0 0 / 45%); + font-size: 12.5px; + line-height: 1.5; + opacity: 0; + translate: 0 10px; + pointer-events: none; + transition: + opacity 160ms ease, + translate 340ms var(--ease); +} + +@supports (corner-shape: squircle) { + .toast { + border-radius: 16px; + corner-shape: squircle; + } +} + +.toast.visible { + opacity: 1; + translate: 0 0; +} + +.toast.error { + border-color: oklch(from var(--red) l c h / 40%); +} + +/* ---------- View transitions ---------- */ + +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 240ms; + animation-timing-function: ease; +} + +::view-transition-old(root) { + animation-name: vt-fade-out; +} + +::view-transition-new(root) { + animation-name: vt-fade-in; +} + +/* ---------- Animations ---------- */ + +@keyframes card-enter { + from { + opacity: 0; + translate: 0 12px; + } +} + +@keyframes vt-fade-out { + to { + opacity: 0; + } +} + +@keyframes vt-fade-in { + from { + opacity: 0; + } +} + +@keyframes pulse { + 50% { + opacity: 0.4; + } +} + +/* ---------- Responsive ---------- */ + +@media (max-width: 720px) { + .appbar { + padding: 10px 14px; + } + + .appbar-actions { + width: 100%; + } + + main { + padding: 14px 14px 56px; + } +} + +/* ---------- Coarse pointers ---------- */ + +@media (pointer: coarse) { + .button, + input { + height: 44px; + } + + .button.small { + height: 40px; + } +} + +/* ---------- Reduced motion ---------- */ + +@media (prefers-reduced-motion: reduce) { + .account-grid:not(.settled) .account-card, + .account-grid.refreshing .quota-gauge, + .link-pill.pending .link-dot, + .status-pill.pending .status-dot { + animation: none; + } + + .button, + .account-card::before, + .quota-gauge, + #field, + main, + dialog, + dialog::backdrop, + .toast { + transition: none; + } +} diff --git a/src/provider/dashboard/app.js b/src/provider/dashboard/app.js new file mode 100644 index 0000000..4a9db50 --- /dev/null +++ b/src/provider/dashboard/app.js @@ -0,0 +1,989 @@ +const csrf = document.querySelector('meta[name="grok-csrf"]').content; +const accountsRoot = document.querySelector('#accounts'); +const addAccount = document.querySelector('#add-account'); +const refreshButton = document.querySelector('#refresh-quotas'); +const statsSummary = document.querySelector('#stats-summary'); +const linkState = document.querySelector('#link-state'); +const linkText = document.querySelector('#link-text'); +const dialog = document.querySelector('#action-dialog'); +const dialogTitle = document.querySelector('#dialog-title'); +const dialogMessage = document.querySelector('#dialog-message'); +const dialogLabel = document.querySelector('#dialog-label'); +const dialogInput = document.querySelector('#dialog-input'); +const dialogConfirm = document.querySelector('#dialog-confirm'); +const dialogCancel = document.querySelector('#dialog-cancel'); +const toastStatus = document.querySelector('#toast'); +const toastAlert = document.querySelector('#toast-alert'); +const srStatus = document.querySelector('#sr-status'); +const fieldCanvas = document.querySelector('#field'); + +let lastState = ''; +let wasOffline = false; +let entranceDone = false; +let timer; +let pendingProviders = new Set(); +let lastProgress = ''; +let quotaRefreshInFlight = false; + +const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)'); + +const element = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +}; + +const makeToast = (node) => { + let dismiss; + const hide = () => { + clearTimeout(dismiss); + node.classList.remove('visible'); + // Clear after the hide transition so stale text leaves the accessibility tree. + const message = node.textContent; + setTimeout(() => { + if (!node.classList.contains('visible') && node.textContent === message) { + node.textContent = ''; + } + }, 400); + }; + node.addEventListener('pointerenter', () => clearTimeout(dismiss)); + node.addEventListener('pointerleave', () => { + if (node.classList.contains('visible')) dismiss = setTimeout(hide, 2500); + }); + return { + hide, + show: (message) => { + clearTimeout(dismiss); + node.textContent = message; + node.classList.add('visible'); + dismiss = setTimeout(hide, 4800); + }, + }; +}; + +const statusToast = makeToast(toastStatus); +const alertToast = makeToast(toastAlert); + +const showToast = (message, error = false) => { + (error ? statusToast : alertToast).hide(); + (error ? alertToast : statusToast).show(message); +}; + +const api = async (path, options = {}) => { + const response = await fetch(path, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-Grok-CSRF': csrf, + ...options.headers, + }, + }); + const body = response.status === 204 ? {} : await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error || `Dashboard request failed (${response.status})`); + return body; +}; + +const mutation = (path, method, body = {}) => api(path, { method, body: JSON.stringify(body) }); + +const modal = ({ title, message, value, confirm = 'Confirm', danger = false, cancel = true }) => + new Promise((resolve) => { + dialogTitle.textContent = title; + dialogMessage.textContent = message; + dialogConfirm.textContent = confirm; + dialogConfirm.className = danger ? 'button primary danger' : 'button primary'; + dialogCancel.hidden = !cancel; + const hasInput = value !== undefined; + dialogLabel.hidden = !hasInput; + dialogInput.hidden = !hasInput; + dialogInput.value = value ?? ''; + const close = () => { + dialog.removeEventListener('close', close); + resolve(dialog.returnValue === 'confirm' ? (hasInput ? dialogInput.value : true) : undefined); + }; + dialog.addEventListener('close', close); + dialog.showModal(); + if (hasInput) dialogInput.select(); + }); + +dialogCancel.addEventListener('click', () => dialog.close('cancel')); +dialog.addEventListener('click', (event) => { + if (event.target === dialog) dialog.close('cancel'); +}); + +const percent = (used, limit) => + !Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0 + ? 0 + : Math.max(0, Math.min(100, (used / limit) * 100)); + +const dateLabel = (value) => + new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)); + +/* ---------- State field backdrop ---------- + * A WebGL2 domain-warped noise field driven by live account state: aggregate + * quota burn raises its energy and shifts the palette indigo → teal → amber, + * errored accounts bleed ember into the warp, sync activity shimmers, and the + * pointer stirs the flow. Reduced motion gets a single composed still frame; + * no WebGL gets the painted CSS fallback (.no-field). */ + +const FIELD_VERTEX = `#version 300 es +layout(location = 0) in vec2 aPos; +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); +}`; + +const FIELD_FRAGMENT = `#version 300 es +precision highp float; + +uniform vec2 uRes; +uniform float uTime; +uniform float uEnergy; +uniform float uAlert; +uniform float uPending; +uniform vec2 uPointer; +uniform float uPointerForce; + +out vec4 outColor; + +float hash(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +float fbm(vec2 p) { + float value = 0.0; + float amp = 0.5; + mat2 rot = mat2(0.8, 0.6, -0.6, 0.8); + for (int i = 0; i < 5; i++) { + value += amp * noise(p); + p = rot * p * 2.02; + amp *= 0.5; + } + return value; +} + +void main() { + vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y; + vec2 toPtr = uv - (uPointer - 0.5 * uRes) / uRes.y; + float stir = uPointerForce * exp(-dot(toPtr, toPtr) * 5.0); + uv += stir * 0.22 * vec2(-toPtr.y, toPtr.x); + + float t = uTime * (0.045 + uEnergy * 0.035 + uPending * 0.02); + float warp = 2.1 + uEnergy * 0.9 + uAlert * 0.5; + + vec2 q = vec2( + fbm(uv * 1.35 + vec2(0.0, t)), + fbm(uv * 1.35 + vec2(5.2, t * 1.3)) + ); + vec2 r = vec2( + fbm(uv * 1.35 + warp * q + vec2(1.7, 9.2) + t * 0.6), + fbm(uv * 1.35 + warp * q + vec2(8.3, 2.8) - t * 0.4) + ); + float f = fbm(uv * 1.35 + (warp + 0.4) * r); + // fbm clusters near 0.5; stretch it so the color bands actually saturate. + f = clamp((f - 0.5) * 2.4 + 0.5, 0.0, 1.0); + + float body = smoothstep(0.3, 0.48, f); + float mid = smoothstep(0.48, 0.66, f); + float core = smoothstep(0.66, 0.92, f); + float glow = 0.55 + 0.45 * uEnergy + 0.2 * uPending; + + vec3 deep = vec3(0.028, 0.032, 0.052); + vec3 indigo = vec3(0.32, 0.38, 0.9); + vec3 teal = vec3(0.2, 0.75, 0.55); + vec3 amber = vec3(0.95, 0.75, 0.25); + vec3 ember = vec3(0.92, 0.3, 0.28); + + float hueArg = clamp((r.y * 0.6 + q.x * 0.4 - 0.5) * 2.6 + 0.5, 0.0, 1.0); + vec3 zone = mix(indigo, teal, smoothstep(0.32, 0.68, hueArg)); + zone = mix(zone, amber, smoothstep(0.6, 0.95, uEnergy) * core); + zone = mix(zone, ember, uAlert * smoothstep(0.32, 0.75, f) * 0.85); + + vec3 col = deep; + col += indigo * 0.03 * (0.5 + 0.5 * q.y); + col = mix(col, zone * 0.45, body); + col = mix(col, zone, mid * 0.9); + col += zone * core * 0.6 * glow; + col += zone * (0.14 * q.x * uPending + stir * 0.3); + + vec2 sp = uv * 70.0; + vec2 cell = floor(sp); + float h = hash(cell); + if (h > 0.99) { + vec2 pos = vec2(hash(cell + 1.3), hash(cell + 2.7)); + float d = length(fract(sp) - pos); + float tw = 0.5 + 0.5 * sin(uTime * (1.0 + h * 3.0) + h * 40.0); + col += vec3(0.75, 0.82, 1.0) * (1.0 - smoothstep(0.0, 0.16, d)) * tw * 0.6; + } + + vec2 vuv = uv * vec2(0.75, 1.0); + col *= clamp(1.0 - 0.35 * dot(vuv, vuv), 0.0, 1.0); + col *= 1.0 + uPending * 0.06 * sin(uTime * 2.4) + uAlert * 0.08 * sin(uTime * 1.3); + col = col / (1.0 + col * 0.6); + col += (hash(gl_FragCoord.xy + vec2(fract(uTime))) - 0.5) * (1.5 / 255.0); + + outColor = vec4(col, 1.0); +}`; + +const createField = (canvas) => { + const gl = canvas.getContext('webgl2', { + alpha: false, + antialias: false, + depth: false, + powerPreference: 'low-power', + stencil: false, + }); + if (!gl) return undefined; + const compile = (type, source) => { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error(gl.getShaderInfoLog(shader) || 'Field shader failed to compile.'); + } + return shader; + }; + try { + const program = gl.createProgram(); + gl.attachShader(program, compile(gl.VERTEX_SHADER, FIELD_VERTEX)); + gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FIELD_FRAGMENT)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(gl.getProgramInfoLog(program) || 'Field shader failed to link.'); + } + gl.useProgram(program); + gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + const uniforms = {}; + for (const name of [ + 'uRes', + 'uTime', + 'uEnergy', + 'uAlert', + 'uPending', + 'uPointer', + 'uPointerForce', + ]) { + uniforms[name] = gl.getUniformLocation(program, name); + } + + // The field renders on a 30fps cadence; rAF ticks faster only to pace the next draw. + const FRAME_MS = 1000 / 30; + const current = { energy: 0.12, alert: 0, pending: 0 }; + const target = { energy: 0.12, alert: 0, pending: 0 }; + const pointer = { x: 0, y: 0, fx: 0, fy: 0, force: 0, forceTarget: 0 }; + let raf = 0; + let last = 0; + let time = 30; + + const pixelScale = () => Math.min(window.devicePixelRatio || 1, 1.5) * 0.5; + + const resize = () => { + const scale = pixelScale(); + const width = Math.max(1, Math.round(canvas.clientWidth * scale)); + const height = Math.max(1, Math.round(canvas.clientHeight * scale)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + gl.viewport(0, 0, width, height); + } + }; + + const draw = () => { + gl.uniform2f(uniforms.uRes, canvas.width, canvas.height); + gl.uniform1f(uniforms.uTime, time); + gl.uniform1f(uniforms.uEnergy, current.energy); + gl.uniform1f(uniforms.uAlert, current.alert); + gl.uniform1f(uniforms.uPending, current.pending); + gl.uniform2f(uniforms.uPointer, pointer.fx, pointer.fy); + gl.uniform1f(uniforms.uPointerForce, pointer.force); + gl.drawArrays(gl.TRIANGLES, 0, 3); + }; + + const frame = (now) => { + raf = 0; + if (now - last < FRAME_MS) { + raf = requestAnimationFrame(frame); + return; + } + const dt = Math.min(0.1, Math.max(0.001, (now - last) / 1000)); + last = now; + time += dt; + const ease = 1 - Math.exp(-dt * 2.2); + current.energy += (target.energy - current.energy) * ease; + current.alert += (target.alert - current.alert) * ease; + current.pending += (target.pending - current.pending) * ease; + const snap = 1 - Math.exp(-dt * 9); + pointer.fx += (pointer.x - pointer.fx) * snap; + pointer.fy += (pointer.y - pointer.fy) * snap; + pointer.forceTarget *= Math.exp(-dt * 1.4); + pointer.force += (pointer.forceTarget - pointer.force) * (1 - Math.exp(-dt * 4)); + draw(); + if (!reduceMotion.matches && !document.hidden) raf = requestAnimationFrame(frame); + }; + + const start = () => { + if (raf || reduceMotion.matches || document.hidden) return; + last = performance.now(); + raf = requestAnimationFrame(frame); + }; + + const stop = () => { + if (!raf) return; + cancelAnimationFrame(raf); + raf = 0; + }; + + const still = () => { + resize(); + draw(); + }; + + window.addEventListener( + 'pointermove', + (event) => { + const scale = pixelScale(); + pointer.x = event.clientX * scale; + pointer.y = (canvas.clientHeight - event.clientY) * scale; + pointer.forceTarget = 1; + }, + { passive: true }, + ); + document.addEventListener('visibilitychange', () => { + if (document.hidden) stop(); + else start(); + }); + reduceMotion.addEventListener('change', () => { + if (reduceMotion.matches) { + stop(); + still(); + return; + } + start(); + }); + canvas.addEventListener('webglcontextlost', (event) => { + event.preventDefault(); + stop(); + document.documentElement.classList.add('no-field'); + }); + + resize(); + new ResizeObserver(() => (raf ? resize() : still())).observe(canvas); + + if (reduceMotion.matches) still(); + else start(); + + return { + setTargets(next) { + Object.assign(target, next); + if (!reduceMotion.matches) return; + Object.assign(current, target); + still(); + }, + }; + } catch { + return undefined; + } +}; + +const field = createField(fieldCanvas); +if (!field) document.documentElement.classList.add('no-field'); + +const updateFieldTargets = (state, offline = false) => { + if (!field) return; + const usages = state.accounts + .filter((account) => account.quota) + .map((account) => percent(account.quota.monthly.used, account.quota.monthly.monthlyLimit)); + const energy = usages.length + ? usages.reduce((sum, value) => sum + value, 0) / usages.length / 100 + : 0.12; + const errors = state.accounts.filter( + (account) => account.login.error || account.login.quotaError, + ).length; + field.setTargets({ + alert: offline ? 0.65 : state.accounts.length ? errors / state.accounts.length : 0, + energy: Math.max(0.12, energy), + pending: + state.refreshing || state.accounts.some((account) => account.login.state === 'pending') + ? 1 + : 0, + }); +}; + +/* ---------- Quota gauges ---------- + * Ring gauges drawn with a conic-gradient over the registered --gauge + * property. Sweeps are animated with WAAPI (registered custom property + * interpolation); where that is unsupported the gauge renders statically. */ + +const gaugeMemory = new Map(); + +const animateGauge = (gauge, from, to) => { + if (reduceMotion.matches) return; + try { + gauge.animate([{ '--gauge': String(from) }, { '--gauge': String(to) }], { + duration: 780, + easing: 'cubic-bezier(0.22, 0.9, 0.24, 1)', + }); + } catch { + // Custom-property WAAPI unsupported: the inline --gauge value already shows the truth. + } +}; + +const quotaRow = (provider, label, usedLabel, metaText, remaining) => { + const row = element('div', 'quota'); + const gauge = element( + 'div', + `quota-gauge${remaining <= 5 ? ' danger' : remaining <= 25 ? ' warning' : ''}`, + ); + gauge.style.setProperty('--gauge', remaining.toFixed(1)); + gauge.setAttribute('role', 'meter'); + gauge.setAttribute('aria-label', `${label}: ${Math.round(remaining)} percent remaining`); + gauge.setAttribute('aria-valuemin', '0'); + gauge.setAttribute('aria-valuemax', '100'); + gauge.setAttribute('aria-valuenow', String(Math.round(remaining))); + gauge.append(element('span', 'gauge-value', `${Math.round(remaining)}%`)); + const key = `${provider}:${label}`; + const previous = gaugeMemory.get(key); + gaugeMemory.set(key, remaining); + if (!entranceDone) animateGauge(gauge, 0, remaining); + else if (previous !== undefined && Math.abs(previous - remaining) > 0.5) { + animateGauge(gauge, previous, remaining); + } + const side = element('div', 'quota-side'); + const header = element('div', 'quota-head'); + header.append(element('span', '', label)); + if (usedLabel) header.append(element('span', 'mono', usedLabel)); + const meta = element('div', 'quota-meta'); + meta.append(element('span', '', metaText)); + side.append(header, meta); + row.append(gauge, side); + return row; +}; + +const quotaUnavailable = (label, reason) => { + const row = element('div', 'quota'); + const header = element('div', 'quota-head'); + header.append(element('span', '', label)); + row.append(header, element('p', 'quota-unavailable', reason)); + return row; +}; + +const PLAN_LABELS = { + free: 'Free plan', + 'supergrok-lite': 'SuperGrok Lite', + supergrok: 'SuperGrok', + 'supergrok-heavy': 'SuperGrok Heavy', +}; + +const statusPill = (account) => { + const variant = account.login.error + ? 'error' + : account.login.state === 'pending' + ? 'pending' + : account.authenticated + ? 'ok' + : ''; + const pill = element('p', `status-pill${variant ? ` ${variant}` : ''}`); + const dot = element('span', 'status-dot'); + dot.setAttribute('aria-hidden', 'true'); + pill.append( + dot, + element('span', '', account.login.state === 'pending' ? 'Logging in…' : account.status), + ); + return pill; +}; + +const actionButton = (label, action, kind = 'ghost') => { + const button = element('button', `button small ${kind}`, label); + button.type = 'button'; + button.dataset.action = label; + button.addEventListener('click', action); + return button; +}; + +const startLogin = async (provider) => { + // Open the popup with the final URL instead of scripting a blank one: embedded + // browsers (e.g. WKWebView) hand window.open('') an unusable about:blank view. + try { + const ticket = await mutation(`/api/accounts/${provider}/login-ticket`, 'POST'); + if (!window.open(ticket.path, `grok-login-${provider}`)) { + showToast('Pop-up blocked. Allow pop-ups, then use Log in on the account card.', true); + return; + } + await refreshState(true, true); + } catch (error) { + showToast(error.message, true); + } +}; + +const loginPanel = (account, isNew) => { + const panel = element('form', 'login-panel'); + panel.append(element('p', '', account.login.progress || 'Waiting for browser authorization…')); + const row = element('div', 'login-row'); + const input = element('input'); + input.name = 'code'; + input.autocomplete = 'off'; + input.placeholder = 'One-time code (if shown)'; + input.setAttribute('aria-label', 'One-time authorization code'); + input.dataset.action = 'code'; + const submit = element('button', 'button small primary', 'Submit code'); + submit.type = 'submit'; + row.append(input, submit); + const cancel = element('button', 'link-button', 'Cancel login'); + cancel.type = 'button'; + cancel.dataset.action = 'Cancel login'; + cancel.addEventListener('click', async () => { + try { + await mutation(`/api/accounts/${account.provider}/login-cancel`, 'POST'); + await refreshState(true, true); + } catch (error) { + showToast(error.message, true); + } + }); + panel.append(row, cancel); + panel.addEventListener('submit', async (event) => { + event.preventDefault(); + if (!input.value.trim()) return; + try { + await mutation(`/api/accounts/${account.provider}/login-code`, 'POST', { + code: input.value, + }); + input.value = ''; + showToast('Code submitted — finishing login…'); + } catch (error) { + showToast(error.message, true); + } + }); + if (isNew) { + panel.animate( + [ + { opacity: 0, translate: '0 8px' }, + { opacity: 1, translate: '0 0' }, + ], + { duration: 340, easing: 'cubic-bezier(0.22, 0.9, 0.24, 1)' }, + ); + } + return panel; +}; + +const cardActions = (account) => { + const actions = element('footer', 'card-actions'); + const activate = async () => { + try { + await mutation(`/api/accounts/${account.provider}/activate`, 'POST'); + await refreshState(true, true); + showToast(`Switched to ${account.label}.`); + } catch (error) { + showToast(error.message, true); + } + }; + const rename = async () => { + const label = await modal({ + title: `Rename ${account.label}`, + message: "Shown here and in pi's account list. Local to this machine.", + value: account.label, + confirm: 'Save label', + }); + if (label === undefined) return; + try { + const updated = await mutation(`/api/accounts/${account.provider}`, 'PATCH', { label }); + await refreshState(true); + showToast(`Renamed to ${updated.label}.`); + } catch (error) { + showToast(error.message, true); + } + }; + const tokenInstructions = async () => { + await modal({ + title: 'Remove environment login', + message: + 'This account logs in with the GROK_CLI_OAUTH_TOKEN environment variable. Unset it and restart pi to remove the account.', + confirm: 'Close', + cancel: false, + }); + }; + const destructive = async () => { + const confirmed = await modal({ + title: + account.provider === 'grok-cli' ? `Log out ${account.label}?` : `Remove ${account.label}?`, + message: + account.provider === 'grok-cli' + ? 'Removes the saved login. The account stays in the list — log in again to use it.' + : 'Removes this account and its saved login. You can add it again with Add account.', + confirm: account.provider === 'grok-cli' ? 'Log out' : 'Remove account', + danger: true, + }); + if (!confirmed) return; + try { + await mutation( + account.provider === 'grok-cli' + ? '/api/accounts/grok-cli/logout' + : `/api/accounts/${account.provider}`, + account.provider === 'grok-cli' ? 'POST' : 'DELETE', + ); + await refreshState(true, true); + showToast( + account.provider === 'grok-cli' + ? `Logged out ${account.label}.` + : `Removed ${account.label}.`, + ); + } catch (error) { + showToast(error.message, true); + } + }; + + if (!account.authenticated && !account.environment) { + actions.append(actionButton('Log in', () => startLogin(account.provider), 'primary')); + } else if (!account.active && account.authenticated) { + actions.append(actionButton('Switch', activate, 'primary')); + } + if (!account.environment && account.authenticated) { + actions.append(actionButton('Log in again', () => startLogin(account.provider))); + } + if (account.environment) { + actions.append(actionButton('How to remove', tokenInstructions)); + } + actions.append(actionButton('Rename', rename)); + if (!account.environment) { + const button = actionButton( + account.provider === 'grok-cli' ? 'Log out' : 'Remove', + destructive, + 'danger push-right', + ); + actions.append(button); + } + return actions; +}; + +const accountCard = (account, index, isNewPending, refreshing) => { + const card = element('article', `account-card${account.active ? ' active' : ''}`); + card.dataset.provider = account.provider; + card.style.viewTransitionName = `card-${account.provider}`; + if (!entranceDone) { + card.style.setProperty('--enter-delay', `${Math.min(index * 45, 220)}ms`); + } + + const head = element('header', 'card-head'); + const titleRow = element('div', 'card-title-row'); + titleRow.append(element('h2', '', account.label)); + if (account.active) titleRow.append(element('span', 'active-badge', 'Active')); + const metaRow = element('div', 'card-meta-row'); + metaRow.append(element('span', 'card-provider', account.provider), statusPill(account)); + if (account.plan) metaRow.append(element('span', 'plan-pill', PLAN_LABELS[account.plan])); + head.append(titleRow, metaRow); + + const body = element('div', 'card-body'); + if (account.login.state === 'pending') { + body.append(loginPanel(account, isNewPending)); + card.append(head, body); + return card; + } + const errorText = account.login.error || account.login.quotaError; + if (account.quota) { + const isFree = account.plan === 'free'; + const monthly = account.quota.monthly; + body.append( + quotaRow( + account.provider, + 'Monthly credits', + isFree ? '' : `${Math.max(0, monthly.monthlyLimit - monthly.used).toLocaleString()} left`, + isFree ? 'Not available' : `Resets ${dateLabel(monthly.billingPeriodEnd)}`, + percent(monthly.monthlyLimit - monthly.used, monthly.monthlyLimit), + ), + ); + body.append( + account.quota.weekly || isFree + ? quotaRow( + account.provider, + 'Weekly credits', + '', + !isFree && account.quota.weekly + ? `Resets ${dateLabel(account.quota.weekly.billingPeriodEnd)}` + : 'Not available', + !isFree && account.quota.weekly + ? Math.max(0, Math.min(100, 100 - account.quota.weekly.creditUsagePercent)) + : 0, + ) + : quotaUnavailable('Weekly credits', 'Not available — try refreshing'), + ); + const freshness = element('p', 'freshness'); + if (!refreshing && !account.quota.fresh) freshness.append(element('span', 'tag', 'Stale')); + freshness.append( + element( + 'span', + '', + refreshing ? 'Refreshing…' : `Updated ${dateLabel(account.quota.updatedAt)}`, + ), + ); + body.append(freshness); + } else if (!errorText) { + body.append( + element( + 'p', + 'card-empty', + account.authenticated + ? 'No quota data yet — refresh to load usage.' + : 'Quota appears here after login.', + ), + ); + } + if (errorText) { + body.append(element('p', 'card-error', errorText)); + } + + card.append(head, body, cardActions(account)); + return card; +}; + +const render = (state) => { + statsSummary.textContent = `${state.accounts.length} account${state.accounts.length === 1 ? '' : 's'}`; + linkState.className = 'link-pill ok'; + linkText.textContent = 'Synced'; + refreshButton.disabled = state.refreshing; + accountsRoot.setAttribute('aria-busy', String(state.refreshing)); + accountsRoot.classList.toggle('refreshing', state.refreshing || quotaRefreshInFlight); + updateFieldTargets(state); + const active = document.activeElement; + const refocus = + active instanceof HTMLElement && accountsRoot.contains(active) && active.dataset.action + ? { + provider: active.closest('[data-provider]')?.dataset.provider, + action: active.dataset.action, + } + : undefined; + const codes = new Map( + [...accountsRoot.querySelectorAll('[data-provider] input[name="code"]')] + .map((input) => [input.closest('[data-provider]').dataset.provider, input.value]) + .filter(([, value]) => value), + ); + if (entranceDone) accountsRoot.classList.add('settled'); + const nextPending = new Set( + state.accounts + .filter((account) => account.login.state === 'pending') + .map((account) => account.provider), + ); + // A login that left pending since the last render resolves audibly, not only visually. + for (const provider of pendingProviders) { + if (nextPending.has(provider)) continue; + const account = state.accounts.find((candidate) => candidate.provider === provider); + if (account?.login.state === 'success') showToast(`Logged in ${account.label}.`); + if (account?.login.state === 'failed') showToast(account.login.error || 'Login failed.', true); + if (account?.login.quotaError) showToast(account.login.quotaError, true); + } + // Login progress goes to a persistent live region: poll re-renders replace the + // panel itself, so aria-live on the panel would never announce anything. + const progress = state.accounts + .filter((account) => account.login.state === 'pending') + .map((account) => account.login.progress || 'Waiting for browser authorization…') + .join(' '); + if (progress !== lastProgress) { + lastProgress = progress; + if (progress) srStatus.textContent = progress; + } + const children = state.accounts.length + ? state.accounts.map((account, index) => + accountCard( + account, + index, + nextPending.has(account.provider) && !pendingProviders.has(account.provider), + state.refreshing || quotaRefreshInFlight, + ), + ) + : [element('p', 'grid-message', 'No accounts configured. Use Add account to connect one.')]; + accountsRoot.replaceChildren(...children); + pendingProviders = nextPending; + entranceDone = true; + for (const [provider, value] of codes) { + const input = accountsRoot.querySelector(`[data-provider="${provider}"] input[name="code"]`); + if (input) input.value = value; + } + if (refocus?.provider) { + accountsRoot + .querySelector(`[data-provider="${refocus.provider}"] [data-action="${refocus.action}"]`) + ?.focus(); + } +}; + +// Structural, user-initiated changes (add / remove / switch / login) morph via +// the View Transitions API; everything else re-renders plainly. +const renderTransition = (state) => { + if ( + reduceMotion.matches || + typeof document.startViewTransition !== 'function' || + accountsRoot.querySelector('input[name="code"]:focus') + ) { + render(state); + return; + } + document.startViewTransition(() => render(state)); +}; + +const schedule = (state) => { + clearTimeout(timer); + if (document.hidden) return; + const pending = + state.refreshing || state.accounts.some((account) => account.login.state === 'pending'); + timer = setTimeout(() => refreshState(), pending ? 2000 : 15000); +}; + +async function refreshState(force = false, animate = false) { + try { + const state = await api('/api/state'); + const serialized = JSON.stringify(state); + if (force || wasOffline || serialized !== lastState) { + lastState = serialized; + if (animate) renderTransition(state); + else render(state); + } + wasOffline = false; + schedule(state); + } catch (error) { + clearTimeout(timer); + linkState.className = 'link-pill error'; + linkText.textContent = 'Offline'; + accountsRoot.setAttribute('aria-busy', 'false'); + accountsRoot.classList.remove('refreshing'); + if (lastState) updateFieldTargets(JSON.parse(lastState), true); + else field?.setTargets({ alert: 0.65, pending: 0 }); + // Keep the last good state on screen once loaded; a stale console beats a blank one. + if (!lastState) { + accountsRoot.replaceChildren( + element( + 'p', + 'grid-message', + 'Dashboard connection lost. Run /grok-cli-accounts gui to reopen it.', + ), + ); + } + if (!wasOffline) { + showToast( + error instanceof TypeError + ? 'Connection to the dashboard server failed. Retrying…' + : error.message, + true, + ); + } + wasOffline = true; + if (!document.hidden) timer = setTimeout(() => refreshState(), 5000); + } +} + +addAccount.addEventListener('click', async () => { + const label = await modal({ + title: 'Add account', + message: + 'Optional label, shown in pi and this dashboard. A browser window opens next for xAI authorization.', + value: '', + confirm: 'Add', + }); + if (label === undefined) return; + try { + const account = await mutation('/api/accounts', 'POST', { label }); + await refreshState(true, true); + await startLogin(account.provider); + } catch (error) { + showToast(error.message, true); + } +}); + +refreshButton.addEventListener('click', async () => { + refreshButton.disabled = true; + quotaRefreshInFlight = true; + accountsRoot.classList.add('refreshing'); + try { + const result = await mutation('/api/quotas/refresh', 'POST'); + showToast( + result.failed.length + ? `Updated ${result.updated} of ${result.updated + result.failed.length} accounts — ${result.failed.length} failed; try logging in again.` + : `Updated ${result.updated} account${result.updated === 1 ? '' : 's'}.`, + ); + } catch (error) { + showToast(error.message, true); + } + quotaRefreshInFlight = false; + await refreshState(true); +}); + +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + clearTimeout(timer); + return; + } + void refreshState(true); +}); + +let lastFocusRefresh = 0; +window.addEventListener('focus', () => { + if (Date.now() - lastFocusRefresh < 5000) return; + lastFocusRefresh = Date.now(); + void refreshState(); +}); + +// Cursor spotlight: proximity lights each card's border through --mx/--my/--glow +// custom properties, so the effect travels across cards without touching layout. +// Resets when the pointer leaves the grid or motion/pointer preferences change. +const setupProximity = () => { + const media = { + motion: matchMedia('(prefers-reduced-motion: no-preference)'), + pointer: matchMedia('(hover: hover) and (pointer: fine)'), + }; + let pointer; + let frame = 0; + const apply = () => { + frame = 0; + const cards = accountsRoot.querySelectorAll('.account-card'); + if (!pointer) { + for (const card of cards) card.style.removeProperty('--glow'); + return; + } + for (const card of cards) { + const rect = card.getBoundingClientRect(); + const dx = Math.max(rect.left - pointer.x, 0, pointer.x - rect.right); + const dy = Math.max(rect.top - pointer.y, 0, pointer.y - rect.bottom); + const t = Math.max(0, 1 - Math.hypot(dx, dy) / 220); + card.style.setProperty('--mx', `${(pointer.x - rect.left).toFixed(1)}px`); + card.style.setProperty('--my', `${(pointer.y - rect.top).toFixed(1)}px`); + card.style.setProperty('--glow', t.toFixed(3)); + } + }; + const scheduleApply = () => { + if (frame) return; + frame = requestAnimationFrame(apply); + }; + accountsRoot.addEventListener('pointermove', (event) => { + if (!media.motion.matches || !media.pointer.matches) return; + pointer = { x: event.clientX, y: event.clientY }; + scheduleApply(); + }); + const reset = () => { + pointer = undefined; + scheduleApply(); + }; + accountsRoot.addEventListener('pointerleave', reset); + window.addEventListener('scroll', scheduleApply, { passive: true }); + window.addEventListener('resize', scheduleApply); + media.motion.addEventListener('change', reset); + media.pointer.addEventListener('change', reset); +}; +setupProximity(); + +void refreshState(true); diff --git a/src/provider/dashboard/index.html b/src/provider/dashboard/index.html new file mode 100644 index 0000000..ba8bb3b --- /dev/null +++ b/src/provider/dashboard/index.html @@ -0,0 +1,62 @@ + + + + + + + Pi Grok CLI + + + + + + + +
+
+ +

Pi Grok CLI

+ + Connecting… +
+
+ + Syncing + + + +
+
+ +
+ +
+ + +
+

+

+ + +
+ + +
+
+
+ +
+
+ + + diff --git a/src/provider/dashboard/server.ts b/src/provider/dashboard/server.ts new file mode 100644 index 0000000..e47970b --- /dev/null +++ b/src/provider/dashboard/server.ts @@ -0,0 +1,572 @@ +import { spawn } from 'node:child_process'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AuthInteraction } from '@earendil-works/pi-ai'; +import type { ExtensionContext } from '@earendil-works/pi-coding-agent'; +import type { AccountManager } from '../accounts.js'; + +const HOST = '127.0.0.1'; +const COOKIE_PREFIX = 'grok_cli_dashboard_'; +const MAX_BODY_BYTES = 8 * 1024; +const DEFAULT_BODY_TIMEOUT_MS = 5_000; +const DEFAULT_IDLE_MS = 15 * 60_000; +const SECURITY_HEADERS = { + 'Cache-Control': 'no-store', + 'Content-Security-Policy': + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', +}; + +class HttpError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + } +} + +export interface AccountDashboardHandle { + origin: string; + bootstrapUrl: string; + csrfToken: string; + isOpen(): boolean; + close(): Promise; +} + +interface DashboardOptions { + bodyTimeoutMs?: number; + idleMs?: number; + refreshAfterLogin?: boolean; +} + +interface AccountDashboardOptions extends DashboardOptions { + launchBrowser?: (url: string) => Promise; +} + +type LoginState = 'pending' | 'success' | 'failed' | 'cancelled'; + +interface LoginJob { + controller: AbortController; + state: LoginState; + progress?: string; + error?: string; + quotaError?: string; + resolveManualCode: (code: string) => void; +} + +function safeEqual(left: string, right: string) { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && timingSafeEqual(a, b); +} + +function cookieValue(req: IncomingMessage, cookieName: string) { + return req.headers.cookie + ?.split(';') + .map((part) => part.trim().split('=')) + .find(([name]) => name === cookieName)?.[1]; +} + +function send(res: ServerResponse, status: number, body = '', contentType = 'text/plain') { + res.writeHead(status, { + ...SECURITY_HEADERS, + 'Content-Type': `${contentType}; charset=utf-8`, + }); + res.end(body); +} + +function json(res: ServerResponse, status: number, value: unknown) { + send(res, status, JSON.stringify(value), 'application/json'); +} + +function readJson(req: IncomingMessage, timeoutMs: number) { + if (!req.headers['content-type']?.toLowerCase().startsWith('application/json')) { + throw new HttpError(415, 'Expected application/json.'); + } + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + let timer: ReturnType | undefined; + const cleanup = (keepErrorListener = false) => { + if (timer) clearTimeout(timer); + req.removeListener('data', onData); + req.removeListener('end', onEnd); + req.removeListener('aborted', onAborted); + if (!keepErrorListener) req.removeListener('error', onError); + }; + const fail = (error: unknown) => { + cleanup(true); + req.once('close', () => req.removeListener('error', onError)); + reject(error); + }; + const onData = (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_BODY_BYTES) { + fail(new HttpError(413, 'Request body is too large.')); + return; + } + chunks.push(buffer); + }; + const onEnd = () => { + cleanup(); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown); + } catch { + reject(new HttpError(400, 'Request body must be valid JSON.')); + } + }; + const onAborted = () => fail(new HttpError(400, 'Request body was interrupted.')); + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + req.on('data', onData); + req.once('end', onEnd); + req.once('aborted', onAborted); + req.once('error', onError); + timer = setTimeout(() => fail(new HttpError(408, 'Request body timed out.')), timeoutMs); + timer.unref(); + }); +} + +function objectBody(value: unknown) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new HttpError(400, 'Request body must be a JSON object.'); + } + return value as Record; +} + +function publicError(error: unknown) { + if (!(error instanceof Error)) return 'The dashboard action failed.'; + return error.message.replace(/https?:\/\/\S+/g, '[redacted URL]').slice(0, 240); +} + +function escapeHtml(value: string) { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +function accountProvider(pathname: string, suffix = '') { + const match = new RegExp(`^/api/accounts/(grok-cli(?:-(?:[2-9]|[1-9][0-9]+))?)${suffix}$`).exec( + pathname, + ); + return match?.[1]; +} + +function trustedAuthorizationUrl(value: string) { + const url = new URL(value); + const hostname = url.hostname.toLowerCase(); + if ( + url.protocol !== 'https:' || + (hostname !== 'x.ai' && + hostname !== 'accounts.x.ai' && + hostname !== 'auth.x.ai' && + !hostname.endsWith('.x.ai')) + ) { + throw new Error('Login blocked — the authorization URL was not a trusted x.ai address.'); + } + return url.toString(); +} + +function launchBrowser(target: string) { + const command: [string, string[]] = + process.platform === 'darwin' + ? ['open', [target]] + : process.platform === 'win32' + ? ['rundll32', ['url.dll,FileProtocolHandler', target]] + : ['xdg-open', [target]]; + return new Promise((resolve) => { + const child = spawn(command[0], command[1], { + detached: true, + stdio: 'ignore', + }); + let settled = false; + const finish = (result: boolean) => { + if (settled) return; + settled = true; + resolve(result); + }; + child.once('spawn', () => finish(true)); + child.once('error', () => finish(false)); + child.unref(); + }); +} + +export function createAccountDashboard( + manager: AccountManager, + options: AccountDashboardOptions = {}, +) { + let dashboard: AccountDashboardHandle | undefined; + return { + async open(ctx: ExtensionContext) { + if (!dashboard?.isOpen()) { + dashboard = await startAccountDashboard(manager, ctx, options); + } + if (!(await (options.launchBrowser ?? launchBrowser)(dashboard.bootstrapUrl))) { + ctx.ui.notify( + `Could not open the account dashboard automatically. Open this private local URL (do not share it): ${dashboard.bootstrapUrl}`, + 'warning', + ); + } + return dashboard; + }, + async close() { + await dashboard?.close(); + dashboard = undefined; + }, + }; +} + +export async function startAccountDashboard( + manager: AccountManager, + ctx: ExtensionContext, + options: DashboardOptions = {}, +): Promise { + const capability = randomBytes(32).toString('base64url'); + const cookieName = `${COOKIE_PREFIX}${randomBytes(8).toString('hex')}`; + const csrfToken = randomBytes(32).toString('base64url'); + const html = readFileSync(new URL('./index.html', import.meta.url), 'utf8').replace( + '__GROK_CSRF_TOKEN__', + csrfToken, + ); + const css = readFileSync(new URL('./app.css', import.meta.url), 'utf8'); + const javascript = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); + let origin = ''; + let expectedHost = ''; + let idleTimer: ReturnType | undefined; + let closing: Promise | undefined; + let refreshController: AbortController | undefined; + const loginTickets = new Map(); + const loginJobs = new Map(); + + const server = createServer((req, res) => { + void handle(req, res).catch((error: unknown) => { + if (res.headersSent) { + res.end(); + return; + } + const status = error instanceof HttpError ? error.status : 500; + if (status === 408 || status === 413) res.shouldKeepAlive = false; + if (req.headers.accept?.includes('text/html')) { + send( + res, + status, + `Pi Grok CLI

Pi Grok CLI

${escapeHtml(publicError(error))}

`, + 'text/html', + ); + return; + } + json(res, status, { error: publicError(error) }); + }); + }); + + const close = () => { + if (closing) return closing; + if (idleTimer) clearTimeout(idleTimer); + refreshController?.abort(); + for (const job of loginJobs.values()) { + if (job.state === 'pending') job.controller.abort(); + job.resolveManualCode(''); + } + closing = new Promise((resolve) => { + if (!server.listening) { + resolve(); + return; + } + server.close(() => resolve()); + server.closeAllConnections(); + }); + return closing; + }; + + const touch = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => void close(), options.idleMs ?? DEFAULT_IDLE_MS); + idleTimer.unref(); + }; + + const requireMutation = (req: IncomingMessage) => { + if (req.headers.origin !== origin || req.headers['x-grok-csrf'] !== csrfToken) { + throw new HttpError( + 403, + 'The dashboard rejected this request — reload the page and try again.', + ); + } + }; + + const state = () => { + const snapshot = manager.snapshot(ctx); + return { + refreshing: Boolean(refreshController), + accounts: snapshot.accounts.map((account) => { + const job = loginJobs.get(account.provider); + return { + ...account, + login: job + ? { + state: job.state, + ...(job.progress ? { progress: job.progress } : {}), + ...(job.error ? { error: job.error } : {}), + ...(job.quotaError ? { quotaError: job.quotaError } : {}), + } + : { state: 'idle' }, + }; + }), + }; + }; + + const startLogin = async (provider: string, res: ServerResponse) => { + const account = manager + .snapshot(ctx) + .accounts.find((candidate) => candidate.provider === provider); + if (!account) throw new HttpError(404, 'Account not found — it may have been removed.'); + if (account.environment) { + throw new HttpError( + 409, + 'This account logs in with the GROK_CLI_OAUTH_TOKEN environment variable.', + ); + } + if (loginJobs.get(provider)?.state === 'pending') { + throw new HttpError(409, 'A login is already in progress for this account.'); + } + const controller = new AbortController(); + let resolveManualCode = (_code: string) => {}; + const manualCode = new Promise((resolve) => { + resolveManualCode = resolve; + }); + const job: LoginJob = { + controller, + state: 'pending', + progress: 'Waiting for xAI authorization', + resolveManualCode, + }; + loginJobs.set(provider, job); + let settledRedirect = false; + let finishRedirect = () => {}; + const redirected = new Promise((resolve) => { + finishRedirect = resolve; + }); + const interaction: AuthInteraction = { + signal: controller.signal, + notify(event) { + if (event.type === 'progress' || event.type === 'info') { + job.progress = publicError(new Error(event.message)); + return; + } + if (event.type === 'device_code') { + throw new Error('Device-code login is not available in the dashboard — use the pi TUI.'); + } + const location = trustedAuthorizationUrl(event.url); + settledRedirect = true; + res.writeHead(302, { ...SECURITY_HEADERS, Location: location }); + res.end(); + finishRedirect(); + }, + async prompt(prompt) { + if (prompt.type === 'manual_code') return manualCode; + if (prompt.type === 'select') return 'browser'; + throw new Error('Interactive OAuth prompts are not supported in the dashboard.'); + }, + }; + void manager.login(ctx, provider, interaction).then( + async () => { + job.state = 'success'; + job.progress = 'Login complete'; + job.resolveManualCode(''); + if (options.refreshAfterLogin === false) return; + try { + const result = await manager.refreshOne(ctx, provider, controller.signal); + if (result.failed.length) job.quotaError = 'Login succeeded, but quota refresh failed.'; + } catch { + job.quotaError = 'Login succeeded, but quota refresh failed.'; + } + }, + (error: unknown) => { + job.state = controller.signal.aborted ? 'cancelled' : 'failed'; + job.error = controller.signal.aborted ? 'Login cancelled.' : publicError(error); + job.resolveManualCode(''); + if (!settledRedirect) { + send( + res, + 502, + 'Login failed

Grok login failed

You can close this window and retry from the dashboard.

', + 'text/html', + ); + finishRedirect(); + } + }, + ); + await redirected; + }; + + async function handle(req: IncomingMessage, res: ServerResponse) { + touch(); + if (req.headers.host !== expectedHost) throw new HttpError(421, 'Invalid dashboard host.'); + const url = new URL(req.url ?? '/', origin); + if (req.method === 'GET' && url.pathname === `/bootstrap/${capability}`) { + res.writeHead(302, { + ...SECURITY_HEADERS, + Location: '/', + 'Set-Cookie': `${cookieName}=${capability}; HttpOnly; SameSite=Strict; Path=/`, + }); + res.end(); + return; + } + if (!safeEqual(cookieValue(req, cookieName) ?? '', capability)) { + throw new HttpError( + 401, + 'Dashboard session expired — reopen it with /grok-cli-accounts gui.', + ); + } + if (req.method === 'GET' && url.pathname === '/') { + send(res, 200, html, 'text/html'); + return; + } + if (req.method === 'GET' && url.pathname === '/app.css') { + send(res, 200, css, 'text/css'); + return; + } + if (req.method === 'GET' && url.pathname === '/app.js') { + send(res, 200, javascript, 'text/javascript'); + return; + } + if (req.method === 'GET' && url.pathname === '/api/state') { + json(res, 200, state()); + return; + } + if (req.method === 'POST' && url.pathname === '/api/accounts') { + requireMutation(req); + const body = objectBody( + await readJson(req, options.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS), + ); + if (body.label !== undefined && typeof body.label !== 'string') { + throw new HttpError(400, 'Account label must be text.'); + } + json(res, 201, await manager.add(ctx, body.label ?? '')); + return; + } + const renameProvider = accountProvider(url.pathname); + if (req.method === 'PATCH' && renameProvider) { + requireMutation(req); + const body = objectBody( + await readJson(req, options.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS), + ); + if (typeof body.label !== 'string') { + throw new HttpError(400, 'Account label must be text.'); + } + json(res, 200, await manager.rename(ctx, renameProvider, body.label)); + return; + } + const activateProvider = accountProvider(url.pathname, '/activate'); + if (req.method === 'POST' && activateProvider) { + requireMutation(req); + json(res, 200, await manager.activate(ctx, activateProvider)); + return; + } + const logoutProvider = accountProvider(url.pathname, '/logout'); + if (req.method === 'POST' && logoutProvider) { + requireMutation(req); + json(res, 200, await manager.logout(ctx, logoutProvider)); + return; + } + if (req.method === 'DELETE' && renameProvider) { + requireMutation(req); + json(res, 200, await manager.remove(ctx, renameProvider)); + return; + } + if (req.method === 'POST' && url.pathname === '/api/quotas/refresh') { + requireMutation(req); + if (refreshController) throw new HttpError(409, 'A quota refresh is already running.'); + refreshController = new AbortController(); + try { + json(res, 200, await manager.refresh(ctx, refreshController.signal)); + } finally { + refreshController = undefined; + } + return; + } + const ticketProvider = accountProvider(url.pathname, '/login-ticket'); + if (req.method === 'POST' && ticketProvider) { + requireMutation(req); + const account = manager + .snapshot(ctx) + .accounts.find((candidate) => candidate.provider === ticketProvider); + if (!account) throw new HttpError(404, 'Account not found — it may have been removed.'); + if (account.environment) { + throw new HttpError( + 409, + 'This account logs in with the GROK_CLI_OAUTH_TOKEN environment variable.', + ); + } + const ticket = randomBytes(24).toString('base64url'); + loginTickets.set(ticket, { provider: ticketProvider, expiresAt: Date.now() + 60_000 }); + json(res, 201, { path: `/oauth/${ticket}` }); + return; + } + const ticket = /^\/oauth\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1]; + if (req.method === 'GET' && ticket) { + const loginTicket = loginTickets.get(ticket); + loginTickets.delete(ticket); + if (!loginTicket || loginTicket.expiresAt < Date.now()) { + throw new HttpError(404, 'Login link expired — start again from the dashboard.'); + } + await startLogin(loginTicket.provider, res); + return; + } + const codeProvider = accountProvider(url.pathname, '/login-code'); + if (req.method === 'POST' && codeProvider) { + requireMutation(req); + const body = objectBody( + await readJson(req, options.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS), + ); + if (typeof body.code !== 'string' || !body.code.trim()) { + throw new HttpError(400, 'Authorization code is required.'); + } + const job = loginJobs.get(codeProvider); + if (job?.state !== 'pending') throw new HttpError(409, 'No login is waiting for a code.'); + job.resolveManualCode(body.code.trim()); + json(res, 202, { accepted: true }); + return; + } + const cancelProvider = accountProvider(url.pathname, '/login-cancel'); + if (req.method === 'POST' && cancelProvider) { + requireMutation(req); + const job = loginJobs.get(cancelProvider); + if (job?.state !== 'pending') throw new HttpError(409, 'No login is running.'); + job.controller.abort(); + job.resolveManualCode(''); + json(res, 202, { cancelled: true }); + return; + } + throw new HttpError(404, 'Dashboard route not found.'); + } + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, HOST, () => { + server.removeListener('error', reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + await close(); + throw new Error('Could not determine the dashboard address.'); + } + expectedHost = `${HOST}:${address.port}`; + origin = `http://${expectedHost}`; + server.unref(); + touch(); + + return { + origin, + bootstrapUrl: `${origin}/bootstrap/${capability}`, + csrfToken, + isOpen: () => server.listening, + close, + }; +} diff --git a/src/provider/quotaCache.ts b/src/provider/quotaCache.ts new file mode 100644 index 0000000..9b67af5 --- /dev/null +++ b/src/provider/quotaCache.ts @@ -0,0 +1,149 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { getQuotaCachePath, writeFileAtomic } from '../storage.js'; +import type { BillingUsage, MonthlyUsage, WeeklyUsage } from './billing.js'; + +export interface CachedQuota extends BillingUsage { + updatedAt: string; +} + +export interface QuotaCache { + version: 1; + accounts: Record; +} + +const QUOTA_FRESHNESS_MS = 30 * 60_000; + +const emptyCache = (): QuotaCache => ({ version: 1, accounts: {} }); + +function isDate(value: unknown): value is string { + return typeof value === 'string' && Number.isFinite(new Date(value).getTime()); +} + +function isMonthlyUsage(value: unknown): value is MonthlyUsage { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const monthly = value as Record; + return ( + typeof monthly.monthlyLimit === 'number' && + Number.isFinite(monthly.monthlyLimit) && + typeof monthly.used === 'number' && + Number.isFinite(monthly.used) && + isDate(monthly.billingPeriodEnd) + ); +} + +function isWeeklyUsage(value: unknown): value is WeeklyUsage { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const weekly = value as Record; + return ( + typeof weekly.creditUsagePercent === 'number' && + Number.isFinite(weekly.creditUsagePercent) && + isDate(weekly.billingPeriodEnd) + ); +} + +function parseCachedQuota(value: unknown): CachedQuota | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const entry = value as Record; + if (!isDate(entry.updatedAt) || !isMonthlyUsage(entry.monthly)) return undefined; + if (entry.weekly !== undefined && !isWeeklyUsage(entry.weekly)) return undefined; + return { + updatedAt: entry.updatedAt, + monthly: entry.monthly, + ...(entry.weekly ? { weekly: entry.weekly } : {}), + }; +} + +export function loadQuotaCache(path = getQuotaCachePath()): QuotaCache { + try { + const raw: unknown = JSON.parse(readFileSync(path, 'utf8')); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return emptyCache(); + const cache = raw as Record; + if ( + cache.version !== 1 || + !cache.accounts || + typeof cache.accounts !== 'object' || + Array.isArray(cache.accounts) + ) { + return emptyCache(); + } + return { + version: 1, + accounts: Object.fromEntries( + Object.entries(cache.accounts).flatMap(([provider, value]) => { + const entry = parseCachedQuota(value); + return entry ? [[provider, entry]] : []; + }), + ), + }; + } catch { + return emptyCache(); + } +} + +const cacheUpdates = new Map>(); + +async function updateQuotaCache( + update: (cache: QuotaCache) => boolean, + path = getQuotaCachePath(), +) { + const previous = cacheUpdates.get(path) ?? Promise.resolve(); + const operation = () => { + const cache = loadQuotaCache(path); + if (!update(cache)) return; + writeFileAtomic(path, `${JSON.stringify(cache, null, 2)}\n`, 0o600); + }; + const next = previous.then(operation, operation); + cacheUpdates.set(path, next); + try { + await next; + } finally { + if (cacheUpdates.get(path) === next) cacheUpdates.delete(path); + } +} + +export function saveQuotaUsage( + provider: string, + usage: BillingUsage, + updatedAt = new Date().toISOString(), + path = getQuotaCachePath(), +) { + return updateQuotaCache((cache) => { + cache.accounts[provider] = { + updatedAt, + monthly: { ...usage.monthly }, + ...(usage.weekly ? { weekly: { ...usage.weekly } } : {}), + }; + return true; + }, path); +} + +export function removeQuotaUsage(provider: string, path = getQuotaCachePath()) { + return updateQuotaCache((cache) => { + if (!existsSync(path) || !cache.accounts[provider]) return false; + delete cache.accounts[provider]; + return true; + }, path); +} + +function formatAge(updatedAt: string, now: number) { + const age = Math.max(0, now - new Date(updatedAt).getTime()); + if (age < 60_000) return 'just now'; + if (age < 3_600_000) return `${Math.floor(age / 60_000)}m ago`; + if (age < 86_400_000) return `${Math.floor(age / 3_600_000)}h ago`; + return `${Math.floor(age / 86_400_000)}d ago`; +} + +export function isCachedQuotaFresh(entry: CachedQuota, now = Date.now()) { + return Math.max(0, now - new Date(entry.updatedAt).getTime()) < QUOTA_FRESHNESS_MS; +} + +export function formatCachedQuota(entry: CachedQuota, now = Date.now()) { + return [ + `Monthly ${entry.monthly.used.toLocaleString()} / ${entry.monthly.monthlyLimit.toLocaleString()} used`, + entry.weekly + ? `Weekly ${Math.round(entry.weekly.creditUsagePercent)}% used` + : 'Weekly unavailable', + ...(!isCachedQuotaFresh(entry, now) ? ['stale'] : []), + formatAge(entry.updatedAt, now), + ].join(' · '); +} diff --git a/src/provider/register.ts b/src/provider/register.ts index 08ea7dd..e85fe13 100644 --- a/src/provider/register.ts +++ b/src/provider/register.ts @@ -1,20 +1,16 @@ -import type { - Api, - Model, - OAuthCredentials, - OAuthLoginCallbacks, - OAuthProviderInterface, -} from '@earendil-works/pi-ai'; -import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; +import type { ExtensionAPI, ProviderConfig } from '@earendil-works/pi-coding-agent'; import * as oauth from '../auth/oauth.js'; import { getBaseUrl, type XaiOAuthCredentials } from '../auth/oauth.js'; -import { migrateLegacyConfig } from '../config.js'; +import { type GrokCliAccount, loadConfig, migrateLegacyConfig } from '../config.js'; import { registerImagineFeature } from '../imagine/register.js'; -import { type GrokCliModelConfig, resolveModels } from '../models/catalog.js'; +import { resolveModels } from '../models/catalog.js'; import { sanitizePayload } from '../payload/sanitize.js'; import { registerGrokTools } from '../tools/register.js'; import { bindLivePiWebAccess, ensureWebSearchDelegate } from '../tools/webSearchDelegate.js'; import { registerVisionFeature } from '../vision/register.js'; +import { isGrokCliProvider, registerAccountManagement } from './accounts.js'; +import { removeQuotaUsage } from './quotaCache.js'; +import { registerExhaustionRotation } from './rotation.js'; import { grokCliModelHeaders } from './stream.js'; import { handoffGrokTools, restoreGrokTools, syncGrokTools } from './toolScope.js'; import { registerUsageCommand } from './usage.js'; @@ -23,55 +19,64 @@ export default function registerGrokCli(pi: ExtensionAPI) { const migration = migrateLegacyConfig(); const baseUrl = getBaseUrl(); const models = resolveModels(); - - const oauthProvider = { - name: 'Grok CLI', - usesCallbackServer: true, - - async login(callbacks: OAuthLoginCallbacks): Promise { - return oauth.login(callbacks); - }, - - async refreshToken(credentials: OAuthCredentials): Promise { - return oauth.refresh(credentials); - }, - - getApiKey(credentials: OAuthCredentials): string { - return credentials.access; - }, - - modifyModels(models: Model[], credentials: OAuthCredentials) { - const effectiveBaseUrl = String( - (credentials as XaiOAuthCredentials).baseUrl ?? getBaseUrl(), - ).replace(/\/+$/, ''); - - return models.map((model) => - model.provider === 'grok-cli' ? { ...model, baseUrl: effectiveBaseUrl } : model, - ); - }, - } satisfies Omit; - - pi.registerProvider('grok-cli', { - name: 'Grok CLI', - baseUrl, - apiKey: '$GROK_CLI_OAUTH_TOKEN', - api: 'openai-responses', - models: models.map((m: GrokCliModelConfig) => ({ - id: m.id, - name: m.name, - reasoning: m.reasoning, - thinkingLevelMap: m.thinkingLevelMap, - input: m.input, - cost: m.cost, - contextWindow: m.contextWindow, - maxTokens: m.maxTokens, - // Carried as model.headers so the version-gate headers reach the server on - // every request even when the API-provider registry reverts to pi-ai's - // built-in openai-responses handler (see grokCliModelHeaders). - headers: grokCliModelHeaders(m.id), - })), - oauth: oauthProvider, - }); + const exhaustionRotation = registerExhaustionRotation(pi); + + const registerAccount = (account: GrokCliAccount) => { + const oauthProvider = { + name: `Grok CLI — ${account.label}`, + usesCallbackServer: true, + + async login(callbacks) { + const credentials = await oauth.login(callbacks); + await removeQuotaUsage(account.provider).catch(() => undefined); + exhaustionRotation.clearRecentExhaustion(account.provider); + return credentials; + }, + + async refreshToken(credentials) { + return oauth.refresh(credentials); + }, + + getApiKey(credentials) { + return credentials.access; + }, + + modifyModels(registeredModels, credentials) { + const effectiveBaseUrl = String( + (credentials as XaiOAuthCredentials).baseUrl ?? getBaseUrl(), + ).replace(/\/+$/, ''); + + return registeredModels.map((model) => + model.provider === account.provider ? { ...model, baseUrl: effectiveBaseUrl } : model, + ); + }, + } satisfies NonNullable; + + pi.registerProvider(account.provider, { + name: `Grok CLI — ${account.label}`, + baseUrl, + ...(account.provider === 'grok-cli' ? { apiKey: '$GROK_CLI_OAUTH_TOKEN' } : {}), + api: 'openai-responses', + models: models.map((model) => ({ + id: model.id, + name: model.name, + reasoning: model.reasoning, + thinkingLevelMap: model.thinkingLevelMap, + input: model.input, + cost: model.cost, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + // Carried as model.headers so the version-gate headers reach the server on + // every request even when the API-provider registry reverts to pi-ai's + // built-in openai-responses handler (see grokCliModelHeaders). + headers: grokCliModelHeaders(model.id), + })), + oauth: oauthProvider, + }); + }; + + loadConfig().config.accounts.items.forEach(registerAccount); + const accountManagement = registerAccountManagement(pi, registerAccount); const { webSearchRegistered } = registerGrokTools(pi); registerImagineFeature(pi); @@ -80,6 +85,7 @@ export default function registerGrokCli(pi: ExtensionAPI) { syncGrokTools(pi, model, { captureDelete, webSearchRegistered }); pi.on('model_select', (event) => { + accountManagement.handleModelSelect(event); syncTools(event.model); }); @@ -110,7 +116,8 @@ export default function registerGrokCli(pi: ExtensionAPI) { syncTools(ctx.model); }); - pi.on('session_shutdown', (event) => { + pi.on('session_shutdown', async (event) => { + await accountManagement.closeDashboard(); syncTools(undefined); if (event.reason === 'new' || event.reason === 'resume' || event.reason === 'fork') { handoffGrokTools(pi, event.targetSessionFile); @@ -118,12 +125,12 @@ export default function registerGrokCli(pi: ExtensionAPI) { }); pi.on('before_provider_headers', (event, ctx) => { - if (ctx.model?.provider !== 'grok-cli') return; + if (!isGrokCliProvider(ctx.model?.provider)) return; event.headers['x-grok-conv-id'] = ctx.sessionManager.getSessionId(); }); pi.on('before_provider_request', (event, ctx) => { - if (ctx.model?.provider !== 'grok-cli') return; + if (!isGrokCliProvider(ctx.model?.provider)) return; const modelId = ctx.model?.id ?? ''; const sessionId = ctx.sessionManager?.getSessionId(); diff --git a/src/provider/rotation.ts b/src/provider/rotation.ts new file mode 100644 index 0000000..ea2880a --- /dev/null +++ b/src/provider/rotation.ts @@ -0,0 +1,206 @@ +import type { AssistantMessage } from '@earendil-works/pi-ai'; +import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; +import { loadConfig, saveConfig } from '../config.js'; +import { DEFAULT_GROK_MODEL, GROK_CLI_PROVIDER, isGrokCliProvider } from './accounts.js'; +import { type CachedQuota, isCachedQuotaFresh, loadQuotaCache } from './quotaCache.js'; + +export const EXHAUSTED_BALANCE_ERROR = + 'OpenAI API error (402): 402 "Grok Build usage balance exhausted"'; +export const ROTATION_CONTINUATION = + 'Continue the previous request using the newly selected Grok account. Do not repeat completed work.'; +const RECENT_EXHAUSTION_COOLDOWN_MS = 5 * 60_000; + +type FailedResponse = { + model: string; + provider: string; +}; + +function isExhaustionMessage(message: unknown): message is AssistantMessage { + if (!message || typeof message !== 'object') return false; + const candidate = message as Partial; + return ( + candidate.role === 'assistant' && + isGrokCliProvider(candidate.provider) && + candidate.stopReason === 'error' && + candidate.errorMessage?.trim() === EXHAUSTED_BALANCE_ERROR + ); +} + +function hasAccountAuth(ctx: ExtensionContext, provider: string) { + return ( + ctx.modelRegistry.getProviderAuthStatus(provider).configured || + (provider === GROK_CLI_PROVIDER && Boolean(process.env.GROK_CLI_OAUTH_TOKEN)) + ); +} + +function circularProviders(providers: string[], current: string) { + const index = providers.indexOf(current); + if (index < 0) return providers; + return [...providers.slice(index + 1), ...providers.slice(0, index)]; +} + +function quotaScore(entry: CachedQuota | undefined, now: number) { + if (!entry || !isCachedQuotaFresh(entry, now) || entry.monthly.monthlyLimit <= 0) { + return undefined; + } + const monthly = Math.min( + 1, + Math.max(0, (entry.monthly.monthlyLimit - entry.monthly.used) / entry.monthly.monthlyLimit), + ); + if (!entry.weekly) return monthly; + return Math.min(monthly, Math.min(1, Math.max(0, 1 - entry.weekly.creditUsagePercent / 100))); +} + +function orderProvidersByQuota( + providers: string[], + accounts: Record, + now: number, +) { + const scored = providers.flatMap((provider, index) => { + const score = quotaScore(accounts[provider], now); + return score === undefined ? [] : [{ provider, index, score }]; + }); + const ranked = [...scored].sort( + (left, right) => right.score - left.score || left.index - right.index, + ); + return providers.map((provider, index) => { + const scoredIndex = scored.findIndex((candidate) => candidate.index === index); + return scoredIndex < 0 ? provider : ranked[scoredIndex].provider; + }); +} + +export function registerExhaustionRotation(pi: ExtensionAPI) { + const exhausted = new Set(); + const unavailable = new Set(); + const recentlyExhausted = new Map(); + let pending: FailedResponse | undefined; + let awaitingContinuation = false; + + const clearChain = () => { + exhausted.clear(); + unavailable.clear(); + pending = undefined; + awaitingContinuation = false; + }; + + const isRecentlyExhausted = (provider: string, now: number) => { + const exhaustedAt = recentlyExhausted.get(provider); + if (exhaustedAt === undefined) return false; + if (now - exhaustedAt < RECENT_EXHAUSTION_COOLDOWN_MS) return true; + recentlyExhausted.delete(provider); + return false; + }; + + pi.on('session_start', () => { + clearChain(); + recentlyExhausted.clear(); + }); + + pi.on('input', (event) => { + if (event.source !== 'extension') clearChain(); + }); + + pi.on('model_select', () => { + if (pending) clearChain(); + }); + + pi.on('message_end', (event) => { + if (!isExhaustionMessage(event.message)) return; + const message = event.message; + const config = loadConfig().config; + if (!config.accounts.items.some((account) => account.provider === message.provider)) { + return; + } + pending = { provider: message.provider, model: message.model }; + exhausted.add(message.provider); + recentlyExhausted.set(message.provider, Date.now()); + awaitingContinuation = false; + }); + + pi.on('agent_settled', async (_event, ctx) => { + if (!pending) { + if (awaitingContinuation) clearChain(); + return; + } + + const failed = pending; + pending = undefined; + if (ctx.model?.provider !== failed.provider || ctx.model.id !== failed.model) { + clearChain(); + return; + } + + const config = loadConfig().config; + const authenticated = config.accounts.items.filter((account) => + hasAccountAuth(ctx, account.provider), + ); + if (authenticated.length < 2) { + clearChain(); + return; + } + + const now = Date.now(); + const authenticatedProviders = new Set(authenticated.map((account) => account.provider)); + const eligible = circularProviders( + config.accounts.items.map((account) => account.provider), + failed.provider, + ).filter( + (provider) => + provider !== failed.provider && + !exhausted.has(provider) && + !isRecentlyExhausted(provider, now) && + !unavailable.has(provider) && + authenticatedProviders.has(provider), + ); + + for (const provider of orderProvidersByQuota(eligible, loadQuotaCache().accounts, now)) { + const model = + ctx.modelRegistry.find(provider, failed.model) ?? + ctx.modelRegistry.find(provider, DEFAULT_GROK_MODEL); + if (!model || !(await pi.setModel(model))) { + unavailable.add(provider); + continue; + } + + const refreshed = loadConfig().config; + const failedLabel = refreshed.accounts.items.find( + (account) => account.provider === failed.provider, + )?.label; + const selected = refreshed.accounts.items.find((account) => account.provider === provider); + if (!failedLabel || !selected) { + unavailable.add(provider); + continue; + } + refreshed.accounts.selectedProvider = provider; + saveConfig(refreshed); + ctx.ui.notify( + `Grok CLI: “${failedLabel}” exhausted; switched to “${selected.label}” and continuing.`, + 'info', + ); + awaitingContinuation = true; + pi.sendUserMessage(ROTATION_CONTINUATION); + return; + } + + if ( + authenticated.every( + (account) => exhausted.has(account.provider) || isRecentlyExhausted(account.provider, now), + ) + ) { + ctx.ui.notify('Grok CLI: all logged-in accounts are exhausted.', 'warning'); + clearChain(); + return; + } + ctx.ui.notify( + 'Grok CLI: no other logged-in account is available for automatic rotation.', + 'warning', + ); + clearChain(); + }); + + return { + clearRecentExhaustion(provider: string) { + recentlyExhausted.delete(provider); + }, + }; +} diff --git a/src/provider/toolScope.ts b/src/provider/toolScope.ts index f04ad1e..e6f1daa 100644 --- a/src/provider/toolScope.ts +++ b/src/provider/toolScope.ts @@ -1,5 +1,6 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { loadConfig } from '../config.js'; +import { isGrokCliProvider } from './accounts.js'; export const SHIM_MODEL_IDS = new Set(['grok-build', 'grok-composer-2.5-fast']); @@ -62,7 +63,7 @@ const toolScopeHandoffs: ToolScopeHandoffStore = globalToolScope.__piGrokCliTool globalToolScope.__piGrokCliToolScopeHandoffs = toolScopeHandoffs; function isLegacyModel(model: ToolModel | undefined) { - return model?.provider === 'grok-cli' && SHIM_MODEL_IDS.has(model.id); + return isGrokCliProvider(model?.provider) && SHIM_MODEL_IDS.has(model?.id ?? ''); } function sameTools(currentTools: string[], nextTools: string[]) { diff --git a/src/provider/usage.ts b/src/provider/usage.ts index 2f8f821..f38d9c6 100644 --- a/src/provider/usage.ts +++ b/src/provider/usage.ts @@ -1,14 +1,16 @@ import type { Api, Model } from '@earendil-works/pi-ai'; import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { XaiOAuthError } from '../shared/errors.js'; +import { isGrokCliProvider, resolveGrokProvider, resolveGrokToken } from './accounts.js'; import { fetchBillingUsage, formatQuota } from './billing.js'; +import { loadQuotaCache, saveQuotaUsage } from './quotaCache.js'; export function registerUsageCommand(pi: Pick) { pi.registerCommand('grok-cli-usage', { description: 'Show Grok CLI provider status, quota, and token health', handler: async (_args, ctx) => { - const token = process.env.GROK_CLI_OAUTH_TOKEN; - if (token) { + const provider = resolveGrokProvider(ctx); + if (provider === 'grok-cli' && process.env.GROK_CLI_OAUTH_TOKEN) { ctx.ui.notify( '⚠️ Grok CLI: using GROK_CLI_OAUTH_TOKEN env bypass — no auto-refresh available', 'warning', @@ -17,13 +19,15 @@ export function registerUsageCommand(pi: Pick) try { const registry = ctx.modelRegistry; - const grokModels = registry.getAll().filter((m: Model) => m.provider === 'grok-cli'); + const grokModels = registry + .getAll() + .filter((model: Model) => isGrokCliProvider(model.provider)); if (grokModels.length === 0) { ctx.ui.notify('Grok CLI: no models registered. Run /login grok-cli first.', 'warning'); return; } - const apiKey = token ?? (await registry.getApiKeyForProvider?.('grok-cli')); + const apiKey = await resolveGrokToken(ctx); if (!apiKey) { ctx.ui.notify(formatQuota(undefined).join('\n'), 'info'); return; @@ -31,13 +35,28 @@ export function registerUsageCommand(pi: Pick) try { ctx.ui.notify('Fetching grok cli usage…', 'info'); - ctx.ui.notify(formatQuota(await fetchBillingUsage(apiKey)).join('\n'), 'info'); + const usage = await fetchBillingUsage(apiKey, AbortSignal.timeout(30_000)); + try { + await saveQuotaUsage(provider, usage); + } catch (error) { + ctx.ui.notify( + `Grok CLI quota cache update failed: ${error instanceof Error ? error.message : String(error)}`, + 'warning', + ); + } + ctx.ui.notify(formatQuota(usage).join('\n'), 'info'); } catch (err) { ctx.ui.notify( `Grok CLI billing refresh failed: ${err instanceof Error ? err.message : String(err)}`, 'warning', ); - ctx.ui.notify(formatQuota(undefined).join('\n'), 'info'); + const cached = loadQuotaCache().accounts[provider]; + ctx.ui.notify( + cached + ? `Grok CLI cached usage from ${cached.updatedAt}:\n${formatQuota(cached).join('\n')}` + : formatQuota(undefined).join('\n'), + 'info', + ); } } catch (err) { const msg = diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..ac1e492 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,61 @@ +import { randomUUID } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; + +const homePath = () => process.env.HOME || homedir(); + +export const getGrokCliDirectory = () => join(homePath(), '.pi', 'grok-cli'); +export const getConfigPath = () => join(getGrokCliDirectory(), 'config.json'); +export const getVisionCachePath = () => join(getGrokCliDirectory(), 'vision-cache.json'); +export const getQuotaCachePath = () => join(getGrokCliDirectory(), 'quota-cache.json'); + +export const getLegacyConfigPath = () => join(homePath(), '.pi', 'grok-cli.json'); +export const getLegacyImagineConfigPath = () => join(homePath(), '.pi', 'grok-cli-imagine.json'); +export const getLegacyVisionConfigPath = () => join(homePath(), '.pi', 'grok-cli-vision.json'); +export const getLegacyVisionCachePath = () => join(homePath(), '.pi', 'grok-cli-vision-cache.json'); + +export function writeFileAtomic(path: string, contents: string, mode?: number) { + const tempPath = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); + mkdirSync(dirname(path), { recursive: true }); + try { + writeFileSync(tempPath, contents, { + encoding: 'utf8', + flag: 'wx', + ...(mode === undefined ? {} : { mode }), + }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + throw error; + } +} + +export function migrateStoredFile(source: string, destination: string, preserveSource = false) { + if (!existsSync(source)) return undefined; + if (existsSync(destination)) { + return `Could not migrate ${source}: ${destination} already exists. The legacy file was preserved.`; + } + let verified = false; + try { + const contents = readFileSync(source, 'utf8'); + writeFileAtomic(destination, contents); + if (readFileSync(destination, 'utf8') !== contents) { + throw new Error(`could not verify ${destination}`); + } + verified = true; + if (!preserveSource) unlinkSync(source); + return undefined; + } catch (error) { + if (!verified && existsSync(destination)) rmSync(destination, { force: true }); + return `Could not migrate ${source} to ${destination}: ${error instanceof Error ? error.message : String(error)}. The legacy file was preserved.`; + } +} diff --git a/src/vision/cache.ts b/src/vision/cache.ts index f710e35..17930e2 100644 --- a/src/vision/cache.ts +++ b/src/vision/cache.ts @@ -1,10 +1,12 @@ import { createHash } from 'node:crypto'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { getLegacyVisionCachePath, getVisionCachePath } from '../storage.js'; export const getCachePath = () => - join(process.env.HOME || homedir(), '.pi', 'grok-cli-vision-cache.json'); + existsSync(getVisionCachePath()) || !existsSync(getLegacyVisionCachePath()) + ? getVisionCachePath() + : getLegacyVisionCachePath(); export interface CacheEntry { createdAt: string; diff --git a/src/vision/describe.ts b/src/vision/describe.ts index e29b136..f830111 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -2,6 +2,7 @@ import type { ImageContent, TextContent } from '@earendil-works/pi-ai'; import type { ExtensionContext, ToolResultEvent } from '@earendil-works/pi-coding-agent'; import { getBaseUrl } from '../auth/oauth.js'; import { loadConfig, type VisionConfig } from '../config.js'; +import { resolveGrokToken } from '../provider/accounts.js'; import { grokCliModelHeaders } from '../provider/stream.js'; import { getCachePath, @@ -240,15 +241,6 @@ function replaceImagesWithText( return skipped ? [...parts, skipped] : parts; } -async function resolveApiKey(ctx: ExtensionContext): Promise { - if (process.env.GROK_CLI_OAUTH_TOKEN) return process.env.GROK_CLI_OAUTH_TOKEN; - try { - return await ctx.modelRegistry.getApiKeyForProvider('grok-cli'); - } catch { - return undefined; - } -} - async function describeSingle( img: ImageContent, index: number, @@ -321,7 +313,7 @@ export async function handleReadResult( const selected = images.slice(0, config.maxImages); const skipped = images.length - selected.length; - const apiKey = await resolveApiKey(ctx); + const apiKey = await resolveGrokToken(ctx); if (!apiKey) { ctx.ui.notify( '[grok-cli-vision] No API key — run /login grok-cli or set GROK_CLI_OAUTH_TOKEN', diff --git a/src/vision/register.ts b/src/vision/register.ts index 969e356..87dabcc 100644 --- a/src/vision/register.ts +++ b/src/vision/register.ts @@ -29,27 +29,19 @@ export function registerVisionFeature(pi: ExtensionAPI) { }, }); - pi.registerCommand('grok-cli-vision:on', { - description: 'Enable grok-cli-vision image routing', + pi.registerCommand('grok-cli-vision', { + description: 'Toggle grok-cli-vision image routing', handler: async (_args, ctx) => { const loaded = loadConfig(); + const enabled = !loaded.config.vision.enabled; saveConfig({ ...loaded.config, - vision: { ...loaded.config.vision, enabled: true }, + vision: { ...loaded.config.vision, enabled }, }); - ctx.ui.notify(`grok-cli-vision: ON (${loaded.config.vision.model})`, 'info'); - }, - }); - - pi.registerCommand('grok-cli-vision:off', { - description: 'Disable grok-cli-vision image routing', - handler: async (_args, ctx) => { - const loaded = loadConfig(); - saveConfig({ - ...loaded.config, - vision: { ...loaded.config.vision, enabled: false }, - }); - ctx.ui.notify('grok-cli-vision: OFF', 'info'); + ctx.ui.notify( + enabled ? `grok-cli-vision: ON (${loaded.config.vision.model})` : 'grok-cli-vision: OFF', + 'info', + ); }, }); diff --git a/tests/auth/grokCredentials.test.ts b/tests/auth/grokCredentials.test.ts deleted file mode 100644 index 6ccfc45..0000000 --- a/tests/auth/grokCredentials.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { parseGrokCredentials, readGrokCredentials } from '../../src/auth/grokCredentials.js'; - -const entryKey = 'https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828'; -const validEntry = { - key: 'official-access', - refresh_token: 'official-refresh', - expires_at: '2030-01-02T03:04:05.000Z', - oidc_issuer: 'https://auth.x.ai', - oidc_client_id: 'b1a00492-073a-47ea-816f-4c329264a828', -}; -const originalHome = process.env.HOME; - -afterEach(() => { - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; -}); - -describe('official Grok CLI credentials', () => { - it('normalizes the verified official credential entry', () => { - expect(parseGrokCredentials({ [entryKey]: validEntry })).toEqual({ - access: 'official-access', - refresh: 'official-refresh', - expires: Date.parse('2030-01-02T03:04:05.000Z'), - tokenEndpoint: 'https://auth.x.ai/oauth2/token', - baseUrl: 'https://cli-chat-proxy.grok.com/v1', - }); - }); - - it('accepts only unambiguous numeric millisecond and ISO string expiry values', () => { - expect( - parseGrokCredentials({ - [entryKey]: { ...validEntry, expires_at: 1_893_459_845_000 }, - })?.expires, - ).toBe(1_893_459_845_000); - expect( - parseGrokCredentials({ - [entryKey]: { ...validEntry, expires_at: 1_893_459_845 }, - }), - ).toBeUndefined(); - expect( - parseGrokCredentials({ - [entryKey]: { ...validEntry, expires_at: '1893459845' }, - }), - ).toBeUndefined(); - }); - - it.each([ - undefined, - null, - {}, - { access_token: validEntry }, - { [entryKey]: { ...validEntry, key: '' } }, - { [entryKey]: { ...validEntry, refresh_token: '' } }, - { [entryKey]: { ...validEntry, oidc_issuer: 'https://example.invalid' } }, - { [entryKey]: { ...validEntry, oidc_client_id: 'legacy-client' } }, - ])('ignores missing, incomplete, or unsupported data: %j', (value) => { - expect(parseGrokCredentials(value)).toBeUndefined(); - }); - - it('returns undefined for a missing or malformed auth file without exposing content', async () => { - const home = await mkdtemp(join(tmpdir(), 'pi-grok-credentials-')); - process.env.HOME = home; - await expect(readGrokCredentials()).resolves.toBeUndefined(); - - await mkdir(join(home, '.grok')); - await writeFile(join(home, '.grok', 'auth.json'), '{"secret":"not-json"'); - await expect(readGrokCredentials()).resolves.toBeUndefined(); - }); - - it('reads the verified entry from ~/.grok/auth.json', async () => { - const home = await mkdtemp(join(tmpdir(), 'pi-grok-credentials-')); - process.env.HOME = home; - await mkdir(join(home, '.grok')); - await writeFile(join(home, '.grok', 'auth.json'), JSON.stringify({ [entryKey]: validEntry })); - await expect(readGrokCredentials()).resolves.toMatchObject({ - access: 'official-access', - refresh: 'official-refresh', - }); - }); -}); diff --git a/tests/auth/oauth.test.ts b/tests/auth/oauth.test.ts index 9e004e6..c95b733 100644 --- a/tests/auth/oauth.test.ts +++ b/tests/auth/oauth.test.ts @@ -1,21 +1,19 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { closeCallbackServer, getBaseUrl, - login as loginWithCredentialReader, + login as oauthLogin, refresh, - type XaiOAuthCredentials, } from '../../src/auth/oauth.js'; import { XaiErrorCode } from '../../src/shared/errors.js'; -type CompleteOAuthLoginCallbacks = Parameters[0]; +type CompleteOAuthLoginCallbacks = Parameters[0]; type OAuthLoginCallbacks = Partial; const login = (callbacks: OAuthLoginCallbacks) => - loginWithCredentialReader(callbacks as CompleteOAuthLoginCallbacks, async () => undefined); -const loginWithCredentialsForTest = ( - callbacks: OAuthLoginCallbacks, - credentials: XaiOAuthCredentials, -) => loginWithCredentialReader(callbacks as CompleteOAuthLoginCallbacks, async () => credentials); + oauthLogin(callbacks as CompleteOAuthLoginCallbacks); const originalEnv = { ...process.env }; const originalFetch = globalThis.fetch; @@ -38,16 +36,6 @@ const deviceDiscoveryDocument = { ...discoveryDocument, device_authorization_endpoint: 'https://auth.x.ai/oauth/device/code', }; -function officialCredentials(expires: number): XaiOAuthCredentials { - return { - access: 'official-access', - refresh: 'official-refresh', - expires, - tokenEndpoint: 'https://auth.x.ai/oauth2/token', - baseUrl: 'https://cli-chat-proxy.grok.com/v1', - }; -} - function deviceAuthorizationResponse(overrides: Record = {}) { return Response.json({ device_code: 'device-code', @@ -452,91 +440,53 @@ describe('OAuth helpers without network access', () => { ); }); - it('offers and returns fresh official Grok credentials without a network request', async () => { - const fetchMock = vi.fn(); - const onSelect = vi.fn(async () => 'existing'); - globalThis.fetch = fetchMock; - - await expect( - loginWithCredentialsForTest( - { onSelect, onDeviceCode: vi.fn() }, - officialCredentials(Date.now() + 60_000), - ), - ).resolves.toMatchObject({ - access: 'official-access', - refresh: 'official-refresh', - baseUrl: 'https://cli-chat-proxy.grok.com/v1', - }); - expect(onSelect).toHaveBeenCalledWith({ - message: 'Select Grok CLI login method:', - options: [ - { id: 'browser', label: 'Browser login (default)' }, - { id: 'device', label: 'Device code login (headless)' }, - { id: 'existing', label: 'Use existing Grok Build login' }, - ], - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('refreshes selected expired official Grok credentials through the normal token path', async () => { - const fetchMock = vi.fn(async () => - Response.json({ access_token: 'refreshed-access', refresh_token: 'refreshed-refresh' }), + it('offers only fresh login methods when an official Grok auth file exists', async () => { + const home = await mkdtemp(join(tmpdir(), 'pi-grok-oauth-')); + process.env.HOME = home; + await mkdir(join(home, '.grok')); + await writeFile( + join(home, '.grok', 'auth.json'), + JSON.stringify({ + 'https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828': { + key: 'official-access', + refresh_token: 'official-refresh', + expires_at: '2030-01-02T03:04:05.000Z', + oidc_issuer: 'https://auth.x.ai', + oidc_client_id: 'b1a00492-073a-47ea-816f-4c329264a828', + }, + }), ); - globalThis.fetch = fetchMock; - - await expect( - loginWithCredentialsForTest({ onSelect: async () => 'existing' }, officialCredentials(0)), - ).resolves.toMatchObject({ - access: 'refreshed-access', - refresh: 'refreshed-refresh', - }); - expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock.mock.calls[0]?.[0]).toBe('https://auth.x.ai/oauth2/token'); - }); - - it('returns to fresh login after official credential refresh fails without exposing tokens', async () => { - const onProgress = vi.fn(); - const onSelect = vi - .fn() - .mockResolvedValueOnce('existing') - .mockResolvedValueOnce('browser'); - const fetchMock = vi.fn(async (input) => { - if (input === 'https://auth.x.ai/oauth2/token') { - return new Response('revoked official credential', { status: 401 }); - } - if (input === 'https://auth.x.ai/.well-known/openid-configuration') { - return Response.json(deviceDiscoveryDocument); - } - return Response.json({ access_token: 'browser-access', refresh_token: 'browser-refresh' }); - }); - globalThis.fetch = fetchMock; - - await expect( - loginWithCredentialsForTest( - { onSelect, onProgress, onDeviceCode: vi.fn(), onAuth: authorizeCallback }, - officialCredentials(0), - ), - ).resolves.toMatchObject({ access: 'browser-access' }); - expect(onProgress).toHaveBeenCalledWith( - 'Existing Grok CLI login could not be refreshed. Choose a fresh login method.', + const fetchMock = mockBrowserLogin( + { + access_token: 'browser-access', + refresh_token: 'browser-refresh', + }, + deviceDiscoveryDocument, ); - expect(JSON.stringify(onProgress.mock.calls)).not.toContain('official-access'); - expect(JSON.stringify(onProgress.mock.calls)).not.toContain('official-refresh'); - }); - - it('ignores official credentials when browser login is selected', async () => { - const fetchMock = mockBrowserLogin({ - access_token: 'browser-access', - refresh_token: 'browser-refresh', - }); - - await expect( - loginWithCredentialsForTest( - { onSelect: async () => 'browser', onAuth: authorizeCallback }, - officialCredentials(Date.now() + 60_000), - ), - ).resolves.toMatchObject({ access: 'browser-access' }); - expect(fetchMock).toHaveBeenCalledTimes(2); + const onSelect = vi.fn(async () => 'browser'); + + try { + await expect( + login({ + onSelect, + onDeviceCode: vi.fn(), + onAuth: authorizeCallback, + }), + ).resolves.toMatchObject({ access: 'browser-access', refresh: 'browser-refresh' }); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith({ + message: 'Select Grok CLI login method:', + options: [ + { id: 'browser', label: 'Browser login (default)' }, + { id: 'device', label: 'Device code login (headless)' }, + ], + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.stringify(fetchMock.mock.calls)).not.toContain('official-access'); + expect(JSON.stringify(fetchMock.mock.calls)).not.toContain('official-refresh'); + } finally { + await rm(home, { recursive: true, force: true }); + } }); it('ignores an invalid-state HTTP callback and accepts the next valid callback', async () => { diff --git a/tests/config.test.ts b/tests/config.test.ts index ee31ad8..4eb2e09 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -18,13 +18,20 @@ import { import { useTempHome } from './vision/helpers.js'; const setupHome = useTempHome(); +const FEATURE_CONFIG = { + ...DEFAULT_CONFIG, + imagine: { enabled: false }, + vision: { ...DEFAULT_CONFIG.vision, maxImages: 2 }, +}; function paths(home: string) { return { - cache: join(home, '.pi', 'grok-cli-vision-cache.json'), - config: join(home, '.pi', 'grok-cli.json'), + cache: join(home, '.pi', 'grok-cli', 'vision-cache.json'), + config: join(home, '.pi', 'grok-cli', 'config.json'), + data: join(home, '.pi', 'grok-cli'), debug: join(home, '.pi', 'grok-cli-vision-debug.log'), imagine: join(home, '.pi', 'grok-cli-imagine.json'), + legacyCache: join(home, '.pi', 'grok-cli-vision-cache.json'), pi: join(home, '.pi'), quota: join(home, '.pi', 'grok-cli-quota.json'), tools: join(home, '.pi', 'grok-cli-tools.json'), @@ -36,6 +43,11 @@ function writeJson(path: string, value: unknown) { writeFileSync(path, JSON.stringify(value)); } +function writeConsolidatedConfig(home: string, value: unknown) { + mkdirSync(paths(home).data, { recursive: true }); + writeJson(paths(home).config, value); +} + function withoutDirectoryWrites(directory: string, action: () => T) { chmodSync(directory, 0o500); try { @@ -57,28 +69,17 @@ describe('Grok CLI configuration', () => { it('atomically saves and loads the versioned configuration', () => { const home = setupHome(); - saveConfig({ - ...DEFAULT_CONFIG, - imagine: { enabled: false }, - vision: { ...DEFAULT_CONFIG.vision, maxImages: 2 }, - }); + saveConfig(FEATURE_CONFIG); - expect(loadConfig()).toEqual({ - config: { - ...DEFAULT_CONFIG, - imagine: { enabled: false }, - vision: { ...DEFAULT_CONFIG.vision, maxImages: 2 }, - }, - }); - expect(JSON.parse(readFileSync(paths(home).config, 'utf8')).version).toBe(1); - expect(readdirSync(paths(home).pi).filter((name) => name.endsWith('.tmp'))).toEqual([]); + expect(loadConfig()).toEqual({ config: FEATURE_CONFIG }); + expect(JSON.parse(readFileSync(paths(home).config, 'utf8')).version).toBe(2); + expect(readdirSync(paths(home).data).filter((name) => name.endsWith('.tmp'))).toEqual([]); }); it('fills missing sections and normalizes invalid fields with warnings', () => { const home = setupHome(); - mkdirSync(paths(home).pi, { recursive: true }); - writeJson(paths(home).config, { - version: 1, + writeConsolidatedConfig(home, { + version: 2, vision: { enabled: 'yes', maxImages: -1, cacheEnabled: false }, }); @@ -96,14 +97,14 @@ describe('Grok CLI configuration', () => { it('falls back to legacy settings without overwriting an unsupported version', () => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); - writeJson(paths(home).config, { version: 2, imagine: { enabled: false } }); + writeConsolidatedConfig(home, { version: 3, imagine: { enabled: false } }); writeJson(paths(home).imagine, { enabled: false }); const migration = migrateLegacyConfig(); expect(migration.warning).toMatch(/Unsupported.*version/); expect(loadConfig().config.imagine.enabled).toBe(false); - expect(JSON.parse(readFileSync(paths(home).config, 'utf8')).version).toBe(2); + expect(JSON.parse(readFileSync(paths(home).config, 'utf8')).version).toBe(3); expect(existsSync(paths(home).imagine)).toBe(true); }); @@ -118,14 +119,14 @@ describe('Grok CLI configuration', () => { cacheEnabled: false, cacheMaxEntries: 25, }); - for (const path of [paths(home).cache, paths(home).tools, paths(home).quota]) { + for (const path of [paths(home).legacyCache, paths(home).tools, paths(home).quota]) { writeJson(path, { keep: true }); } writeFileSync(paths(home).debug, 'keep'); expect(migrateLegacyConfig()).toEqual({}); expect(loadConfig().config).toEqual({ - version: 1, + ...DEFAULT_CONFIG, imagine: { enabled: false }, vision: { enabled: false, @@ -137,16 +138,81 @@ describe('Grok CLI configuration', () => { }); expect(existsSync(paths(home).imagine)).toBe(false); expect(existsSync(paths(home).vision)).toBe(false); - for (const path of [ - paths(home).cache, - paths(home).tools, - paths(home).quota, - paths(home).debug, - ]) { + expect(existsSync(paths(home).legacyCache)).toBe(false); + expect(JSON.parse(readFileSync(paths(home).cache, 'utf8'))).toEqual({ keep: true }); + for (const path of [paths(home).tools, paths(home).quota, paths(home).debug]) { expect(existsSync(path)).toBe(true); } }); + it('migrates version 1 in place while preserving existing feature settings', () => { + const home = setupHome(); + writeConsolidatedConfig(home, { ...FEATURE_CONFIG, version: 1 }); + + expect(loadConfig()).toEqual({ config: FEATURE_CONFIG }); + expect(JSON.parse(readFileSync(paths(home).config, 'utf8')).version).toBe(1); + + expect(migrateLegacyConfig()).toEqual({}); + expect(JSON.parse(readFileSync(paths(home).config, 'utf8'))).toEqual(FEATURE_CONFIG); + }); + + it('normalizes account metadata and reports discarded entries', () => { + const home = setupHome(); + writeConsolidatedConfig(home, { + ...DEFAULT_CONFIG, + accounts: { + nextAccountNumber: 2, + selectedProvider: 'missing', + items: [ + { provider: 'grok-cli', label: ' Personal ' }, + { provider: 'grok-cli-2', label: 'Work' }, + { provider: 'grok-cli-2', label: 'Duplicate provider' }, + { provider: 'grok-cli-3', label: 'work' }, + { provider: 'grok-cli-10', label: 'Account 10' }, + { provider: 'other', label: 'Other' }, + { provider: 'grok-cli-4', label: 'bad\nlabel' }, + ], + }, + }); + + const loaded = loadConfig(); + + expect(loaded.config.accounts).toEqual({ + nextAccountNumber: 3, + selectedProvider: 'grok-cli', + items: [ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Work' }, + { provider: 'grok-cli-10', label: 'Account 10' }, + ], + }); + expect(loaded.warning).toContain('accounts'); + }); + + it('reserves the permanent base label when base metadata is missing', () => { + const home = setupHome(); + writeConsolidatedConfig(home, { + ...DEFAULT_CONFIG, + accounts: { + nextAccountNumber: 2, + selectedProvider: 'grok-cli-2', + items: [ + { provider: 'grok-cli-2', label: 'Account 1' }, + { provider: 'grok-cli-3', label: 'Work' }, + ], + }, + }); + + expect(loadConfig().config.accounts).toEqual({ + nextAccountNumber: 2, + selectedProvider: 'grok-cli', + items: [ + { provider: 'grok-cli', label: 'Account 1' }, + { provider: 'grok-cli-3', label: 'Work' }, + ], + }); + }); + it.each(['grok-cli', 'all'])('migrates released Imagine scope %s to enabled', (scope) => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); @@ -206,7 +272,7 @@ describe('Grok CLI configuration', () => { it('keeps a valid consolidated file authoritative and cleans recognized legacy files', () => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); - writeJson(paths(home).config, { + writeConsolidatedConfig(home, { ...DEFAULT_CONFIG, imagine: { enabled: false }, }); @@ -224,7 +290,7 @@ describe('Grok CLI configuration', () => { it('preserves malformed legacy files beside a valid consolidated config', () => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); - writeJson(paths(home).config, DEFAULT_CONFIG); + writeConsolidatedConfig(home, DEFAULT_CONFIG); writeFileSync(paths(home).imagine, '{ nope'); expect(migrateLegacyConfig().warning).toMatch(/Could not read/); @@ -235,7 +301,7 @@ describe('Grok CLI configuration', () => { it('preserves a recognized legacy file when cleanup fails and retries later', () => { const home = setupHome(); mkdirSync(paths(home).pi, { recursive: true }); - writeJson(paths(home).config, DEFAULT_CONFIG); + writeConsolidatedConfig(home, DEFAULT_CONFIG); writeJson(paths(home).imagine, { enabled: false }); const migration = withoutDirectoryWrites(paths(home).pi, migrateLegacyConfig); diff --git a/tests/imagine/config.test.ts b/tests/imagine/config.test.ts index 80555da..66736b8 100644 --- a/tests/imagine/config.test.ts +++ b/tests/imagine/config.test.ts @@ -1,5 +1,5 @@ import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname } from 'node:path'; import { describe, expect, it } from 'vitest'; import { DEFAULT_CONFIG, getConfigPath, loadConfig, saveConfig } from '../../src/config.js'; import { useTempHome } from '../vision/helpers.js'; @@ -28,8 +28,8 @@ describe('Imagine configuration', () => { }); it('falls back safely for malformed JSON', () => { - const home = setupHome(); - mkdirSync(join(home, '.pi'), { recursive: true }); + setupHome(); + mkdirSync(dirname(getConfigPath()), { recursive: true }); writeFileSync(getConfigPath(), '{ nope'); expect(loadConfig().warning).toContain('Could not read'); }); diff --git a/tests/imagine/register.test.ts b/tests/imagine/register.test.ts index 6eff825..4e2d0f7 100644 --- a/tests/imagine/register.test.ts +++ b/tests/imagine/register.test.ts @@ -4,7 +4,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; import { registerImagineFeature } from '../../src/imagine/register.js'; -import { useTempHome } from '../vision/helpers.js'; +import { saveTestAccounts, useTempHome } from '../vision/helpers.js'; import { imagineDependencies } from './helpers.js'; const setupHome = useTempHome(); @@ -20,6 +20,7 @@ function setup(token?: string, initialActiveTools: readonly string[] = ['read']) const setActiveTools = vi.fn((toolsToActivate: string[]) => { activeTools = [...toolsToActivate]; }); + const getApiKeyForProvider = vi.fn(async () => token); registerImagineFeature( { registerCommand(name: string, command: unknown) { @@ -46,7 +47,7 @@ function setup(token?: string, initialActiveTools: readonly string[] = ['read']) cwd: '/project', model: { provider: 'openai' }, ui: { notify }, - modelRegistry: { getApiKeyForProvider: vi.fn(async () => token) }, + modelRegistry: { getApiKeyForProvider }, sessionManager: { getSessionDir: () => '/sessions', getSessionId: () => 'id', @@ -67,6 +68,7 @@ function setup(token?: string, initialActiveTools: readonly string[] = ['read']) home, setActiveTools, getActiveTools: () => activeTools, + getApiKeyForProvider, }; } @@ -117,6 +119,18 @@ describe('registerImagineFeature command', () => { ); }); + it('uses the last selected Grok alias while a non-Grok model is active', async () => { + const extension = setup('work-token'); + saveTestAccounts(); + + await extension.commands.get('grok-cli-imagine')?.handler('cat', extension.context); + + expect(extension.getApiKeyForProvider).toHaveBeenCalledWith('grok-cli-2'); + expect(extension.generate).toHaveBeenCalledWith( + expect.objectContaining({ token: 'work-token' }), + ); + }); + it('keeps a successful JPEG when PNG preview conversion fails', async () => { const extension = setup('token'); extension.convert.mockResolvedValueOnce(null); diff --git a/tests/models/catalog.test.ts b/tests/models/catalog.test.ts index ee1d3e1..642dcad 100644 --- a/tests/models/catalog.test.ts +++ b/tests/models/catalog.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { resolveModels, supportsReasoningEffort } from '../../src/models/catalog.js'; +import { + resolveModels, + supportsReasoning, + supportsReasoningEffort, +} from '../../src/models/catalog.js'; const originalEnv = { ...process.env }; @@ -15,6 +19,12 @@ describe('model catalog', () => { expect(supportsReasoningEffort('grok-4.20-0309-non-reasoning')).toBe(false); }); + it('reports reasoning support by normalized model name', () => { + expect(supportsReasoning('grok-cli/GROK-BUILD')).toBe(true); + expect(supportsReasoning('grok-cli/GROK-COMPOSER-2.5-fast')).toBe(false); + expect(supportsReasoning('grok-4.20-0309-non-reasoning')).toBe(false); + }); + it('uses fallback models when no override is configured', () => { delete process.env.PI_GROK_CLI_MODELS; @@ -34,7 +44,7 @@ describe('model catalog', () => { input: ['text'], }); expect(models.find((model) => model.id === 'grok-build')).toMatchObject({ - contextWindow: 512_000, + contextWindow: 500_000, }); expect(models.find((model) => model.id === 'grok-4.20-0309-reasoning')).toMatchObject({ cost: { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, @@ -61,5 +71,6 @@ describe('model catalog', () => { maxTokens: 30_000, }); expect(models[1].name).toBe('Grok Build'); + expect(supportsReasoning('grok-cli/CUSTOM-MODEL')).toBe(true); }); }); diff --git a/tests/payload/sanitize.test.ts b/tests/payload/sanitize.test.ts index 22bcfd3..a0bceff 100644 --- a/tests/payload/sanitize.test.ts +++ b/tests/payload/sanitize.test.ts @@ -4,6 +4,18 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { sanitizePayload } from '../../src/payload/sanitize.js'; +const sanitizeReasoning = ( + modelId: string, + reasoning: Record, + include?: string[], +) => + sanitizePayload( + { input: 'plain prompt', include, reasoning, reasoningEffort: reasoning.effort }, + modelId, + undefined, + process.cwd(), + ); + describe('payload sanitization', () => { it('removes unsupported items and moves all instructions', () => { const payload = sanitizePayload( @@ -18,7 +30,7 @@ describe('payload sanitization', () => { { type: 'output_text', text: 'output text instruction' }, ], }, - { type: 'reasoning', content: 'cached reasoning' }, + { type: 'reasoning', content: 'cached reasoning', status: 'completed' }, { role: 'user', content: '' }, { role: 'user', content: 'hello' }, { role: 'system', content: 'later system instruction' }, @@ -36,15 +48,131 @@ describe('payload sanitization', () => { expect(payload.instructions).toBe( 'existing instruction\n\nsystem instruction\n\ndeveloper instruction\noutput text instruction\n\nlater system instruction', ); - expect(payload.input).toEqual([{ role: 'user', content: 'hello' }]); - expect(payload.include).toEqual(['message.output_text']); + expect(payload.input).toEqual([ + { + type: 'reasoning', + content: [{ type: 'reasoning_text', text: 'cached reasoning' }], + }, + { role: 'user', content: 'hello' }, + ]); + expect(payload.include).toEqual(['reasoning.encrypted_content', 'message.output_text']); expect(payload.prompt_cache_retention).toBeUndefined(); - expect(payload.reasoning).toEqual({ effort: 'low' }); + expect(payload.reasoning).toEqual({ effort: 'low', summary: 'auto' }); expect(payload.text).toEqual({ format: { type: 'json_object' } }); expect(payload.response_format).toBeUndefined(); expect(payload.prompt_cache_key).toBe('session-123'); }); + it('preserves encrypted reasoning and drops invalid reasoning-content types', () => { + const payload = sanitizePayload( + { + input: [ + { + type: 'reasoning', + id: 'reasoning-1', + summary: [{ type: 'summary_text', text: 'summary' }], + content: [ + { text: 'missing discriminator' }, + { type: 'future_reasoning_type', text: 'keep discriminator' }, + ], + encrypted_content: 'encrypted-reasoning', + status: 'completed', + future_field: { keep: true }, + }, + ], + include: ['reasoning.encrypted_content'], + }, + 'grok-build', + 'session-123', + process.cwd(), + ); + + expect(payload.input).toEqual([ + { + type: 'reasoning', + id: 'reasoning-1', + summary: [{ type: 'summary_text', text: 'summary' }], + content: [{ type: 'reasoning_text', text: 'missing discriminator' }], + encrypted_content: 'encrypted-reasoning', + future_field: { keep: true }, + }, + ]); + expect(payload.include).toEqual(['reasoning.encrypted_content']); + }); + + it('drops malformed reasoning content while normalizing text parts', () => { + const payload = sanitizePayload( + { + input: [ + { + type: 'reasoning', + content: [ + 'plain text', + null, + 42, + ['nested'], + { ignored: true }, + { text: 'missing discriminator' }, + { type: 'future_reasoning_type', text: 'keep discriminator' }, + ], + }, + ], + }, + 'grok-build', + 'session-123', + process.cwd(), + ); + + expect(payload.input).toEqual([ + { + type: 'reasoning', + content: [ + { type: 'reasoning_text', text: 'plain text' }, + { type: 'reasoning_text', text: 'missing discriminator' }, + ], + }, + ]); + }); + + it('maintains serialized input prefixes across three cumulative turns', () => { + const sanitizeInput = (input: unknown[]) => + sanitizePayload( + { input: structuredClone(input), include: ['reasoning.encrypted_content'] }, + 'grok-build', + 'session-123', + process.cwd(), + ).input as unknown[]; + const firstTurn = [{ role: 'user', content: 'turn one' }]; + const secondTurn = [ + ...firstTurn, + { + type: 'reasoning', + id: 'reasoning-1', + encrypted_content: 'encrypted-1', + status: 'completed', + }, + { role: 'assistant', content: 'answer one' }, + { role: 'user', content: 'turn two' }, + ]; + const thirdTurn = [ + ...secondTurn, + { + type: 'reasoning', + id: 'reasoning-2', + encrypted_content: 'encrypted-2', + status: 'completed', + }, + { role: 'assistant', content: 'answer two' }, + { role: 'user', content: 'turn three' }, + ]; + const first = sanitizeInput(firstTurn); + const second = sanitizeInput(secondTurn); + const third = sanitizeInput(thirdTurn); + + expect(JSON.stringify(second.slice(0, first.length))).toBe(JSON.stringify(first)); + expect(JSON.stringify(third.slice(0, second.length))).toBe(JSON.stringify(second)); + }); + it('preserves existing text while removing response_format', () => { const payload = sanitizePayload( { @@ -61,12 +189,16 @@ describe('payload sanitization', () => { expect(payload.response_format).toBeUndefined(); }); - it('strips reasoning fields for models that do not accept reasoning effort', () => { + it('preserves reasoning summaries for models without effort support', () => { const payload = sanitizePayload( { input: 'plain prompt', - include: ['reasoning.encrypted_content'], - reasoning: { effort: 'high' }, + include: [ + 'message.output_text', + 'reasoning.encrypted_content', + 'reasoning.encrypted_content', + ], + reasoning: { effort: 'high', summary: 'auto', future_option: 'keep' }, reasoningEffort: 'high', prompt_cache_key: 'existing-session', }, @@ -76,12 +208,39 @@ describe('payload sanitization', () => { ); expect(payload.input).toBe('plain prompt'); - expect(payload.reasoning).toBeUndefined(); + expect(payload.reasoning).toEqual({ summary: 'auto', future_option: 'keep' }); expect(payload.reasoningEffort).toBeUndefined(); - expect(payload.include).toBeUndefined(); + expect(payload.include).toEqual(['message.output_text', 'reasoning.encrypted_content']); expect(payload.prompt_cache_key).toBe('existing-session'); }); + it('removes empty reasoning requests without adding an encrypted-content include', () => { + const payload = sanitizeReasoning('grok-build', { effort: 'none' }, ['message.output_text']); + + expect(payload.reasoning).toBeUndefined(); + expect(payload.reasoningEffort).toBeUndefined(); + expect(payload.include).toEqual(['message.output_text']); + }); + + it('adds encrypted-content capture when active reasoning has no include list', () => { + const payload = sanitizeReasoning('grok-build', { effort: 'high', summary: 'detailed' }); + + expect(payload.reasoning).toEqual({ summary: 'detailed' }); + expect(payload.include).toEqual(['reasoning.encrypted_content']); + }); + + it('removes reasoning fields for non-reasoning models', () => { + const payload = sanitizeReasoning( + 'grok-cli/GROK-COMPOSER-2.5-fast', + { effort: 'high', summary: 'auto' }, + ['message.output_text', 'reasoning.encrypted_content'], + ); + + expect(payload.reasoning).toBeUndefined(); + expect(payload.reasoningEffort).toBeUndefined(); + expect(payload.include).toEqual(['message.output_text']); + }); + it('normalizes image parts and rewrites image tool output', () => { const payload = sanitizePayload( { diff --git a/tests/provider/accounts.test.ts b/tests/provider/accounts.test.ts new file mode 100644 index 0000000..bd4ca75 --- /dev/null +++ b/tests/provider/accounts.test.ts @@ -0,0 +1,1111 @@ +import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; +import type { Component } from '@earendil-works/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; +import { + isGrokCliProvider, + planTier, + registerAccountManagement, + resolveGrokProvider, + resolveGrokToken, +} from '../../src/provider/accounts.js'; +import { loadQuotaCache, saveQuotaUsage } from '../../src/provider/quotaCache.js'; +import { oauthCredential, TEST_ACCOUNTS, useTempHome } from '../vision/helpers.js'; + +const setupHome = useTempHome(); +const originalFetch = globalThis.fetch; +const originalToken = process.env.GROK_CLI_OAUTH_TOKEN; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + if (originalToken === undefined) delete process.env.GROK_CLI_OAUTH_TOKEN; + else process.env.GROK_CLI_OAUTH_TOKEN = originalToken; +}); + +function configureAccounts( + selectedProvider = 'grok-cli', + items = TEST_ACCOUNTS, + nextAccountNumber = 3, +) { + setupHome(); + saveConfig({ + ...DEFAULT_CONFIG, + accounts: { nextAccountNumber, selectedProvider, items }, + }); +} + +const authenticatedAccounts = () => ({ + 'grok-cli': oauthCredential('personal'), + 'grok-cli-2': oauthCredential('work'), +}); + +const numberedAccounts = (count: number) => + Array.from({ length: count }, (_value, index) => ({ + provider: index === 0 ? 'grok-cli' : `grok-cli-${index + 1}`, + label: `Account ${index + 1}`, + })); + +function setupRefresh( + accounts: { provider: string }[], + action: (component: Component) => Promise, +) { + return setup({ + auth: Object.fromEntries( + accounts.map((account) => [account.provider, oauthCredential(account.provider)]), + ), + preserveHome: true, + customActions: [action], + }); +} + +async function runAccountsCommand(extension: ReturnType) { + await extension.commands.get('grok-cli-accounts')?.handler('', extension.context); +} + +async function runAccountsCommandWith(extension: ReturnType, args: string) { + await extension.commands.get('grok-cli-accounts')?.handler(args, extension.context); +} + +function setup( + options: { + auth?: Record>; + confirms?: boolean[]; + customActions?: ((component: Component) => Promise)[]; + inputs?: (string | undefined)[]; + model?: { provider: string; id: string }; + preserveHome?: boolean; + selections?: (string | undefined)[]; + setModel?: boolean[]; + } = {}, +) { + if (!options.preserveHome) setupHome(); + const commands = new Map Promise }>(); + const registerAccount = vi.fn(); + const unregisterProvider = vi.fn(); + const setModelResults = [...(options.setModel ?? [true])]; + const setModel = vi.fn(async () => setModelResults.shift() ?? true); + const pi = { + registerCommand(name: string, command: unknown) { + commands.set(name, command as { handler: (args: string, ctx: unknown) => Promise }); + }, + setModel, + unregisterProvider, + } as unknown as ExtensionAPI; + const accountManagement = registerAccountManagement(pi, registerAccount); + + const credentials = new Map(Object.entries(options.auth ?? {})); + const authStorage = { + get: (provider: string) => credentials.get(provider), + has: (provider: string) => credentials.has(provider), + set: (provider: string, credential: ReturnType) => + credentials.set(provider, credential), + }; + const selections = [...(options.selections ?? [])]; + const inputs = [...(options.inputs ?? [])]; + const confirms = [...(options.confirms ?? [])]; + const customActions = [...(options.customActions ?? [])]; + const notify = vi.fn(); + const setEditorText = vi.fn(); + const customRenders: string[][] = []; + const models = new Map(); + for (const provider of ['grok-cli', 'grok-cli-2', 'grok-cli-3']) { + for (const id of ['grok-build', 'grok-composer-2.5-fast']) { + models.set(`${provider}/${id}`, { provider, id }); + } + } + const context = { + model: options.model, + modelRegistry: { + runtime: { + login: vi.fn(), + logout: async (provider: string) => { + credentials.delete(provider); + }, + }, + find: (provider: string, id: string) => models.get(`${provider}/${id}`), + getProviderAuthStatus: (provider: string) => ({ configured: credentials.has(provider) }), + getApiKeyForProvider: async (provider: string) => { + const credential = authStorage.get(provider); + return credential?.type === 'oauth' ? credential.access : undefined; + }, + }, + ui: { + confirm: vi.fn(async () => confirms.shift() ?? false), + custom: vi.fn( + async ( + factory: ( + tui: { requestRender: () => void }, + theme: { bold: (text: string) => string; fg: (_color: string, text: string) => string }, + keybindings: unknown, + done: (value: string | undefined) => void, + ) => Component | Promise, + ) => { + let resolveResult = (_value: string | undefined) => {}; + const result = new Promise((resolve) => { + resolveResult = resolve; + }); + let component: Component; + const tui = { + requestRender() { + customRenders.push(component.render(160)); + }, + }; + component = await factory( + tui, + { bold: (text) => text, fg: (_color, text) => text }, + {}, + resolveResult, + ); + customRenders.push(component.render(160)); + const action = customActions.shift(); + if (action) { + await action(component); + return result; + } + const choice = selections.shift(); + if (choice === undefined) { + component.handleInput?.('\u001b'); + return result; + } + for (let index = 0; index < 20; index += 1) { + const selected = component.render(160).find((line) => line.startsWith('→ ')); + if (selected?.includes(choice)) { + component.handleInput?.('\r'); + return result; + } + component.handleInput?.('\u001b[B'); + } + throw new Error(`Could not select custom UI row: ${choice}`); + }, + ), + input: vi.fn(async () => inputs.shift()), + notify, + select: vi.fn(async () => selections.shift()), + setEditorText, + }, + }; + + return { + authStorage, + accountManagement, + commands, + context, + customRenders, + notify, + registerAccount, + setEditorText, + setModel, + unregisterProvider, + }; +} + +describe('Grok CLI account helpers', () => { + it('recognizes only the base provider and valid numbered aliases', () => { + expect(isGrokCliProvider('grok-cli')).toBe(true); + expect(isGrokCliProvider('grok-cli-2')).toBe(true); + expect(isGrokCliProvider('grok-cli-10')).toBe(true); + expect(isGrokCliProvider('grok-cli-1')).toBe(false); + expect(isGrokCliProvider('grok-cli-work')).toBe(false); + }); + + it('maps monthly credit caps to plan tiers', () => { + expect(planTier(0)).toBe('free'); + expect(planTier(4000)).toBe('supergrok-lite'); + expect(planTier(20000)).toBe('supergrok'); + expect(planTier(20001)).toBe('supergrok-heavy'); + }); + + it('resolves the current Grok alias or the persisted selection for other models', async () => { + configureAccounts('grok-cli-2'); + const getApiKeyForProvider = vi.fn(async (provider: string) => `${provider}-token`); + + expect( + resolveGrokProvider({ + model: { provider: 'grok-cli', id: 'grok-build' }, + } as unknown as Pick), + ).toBe('grok-cli'); + expect( + resolveGrokProvider({ + model: { provider: 'openai', id: 'gpt-5' }, + } as unknown as Pick), + ).toBe('grok-cli-2'); + expect( + await resolveGrokToken({ + model: { provider: 'openai', id: 'gpt-5' }, + modelRegistry: { getApiKeyForProvider }, + } as unknown as Pick), + ).toBe('grok-cli-2-token'); + expect(getApiKeyForProvider).toHaveBeenCalledWith('grok-cli-2'); + }); +}); + +describe('/grok-cli-accounts', () => { + it('rejects unknown arguments without opening the TUI', async () => { + const extension = setup(); + + await runAccountsCommandWith(extension, 'wat'); + + expect(extension.notify).toHaveBeenCalledWith('Usage: /grok-cli-accounts [gui]', 'warning'); + expect(extension.context.ui.custom).not.toHaveBeenCalled(); + }); + + it('produces a credential-free account snapshot for alternate interfaces', async () => { + configureAccounts(); + await saveQuotaUsage('grok-cli-2', { + monthly: { + monthlyLimit: 2000, + used: 700, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + weekly: { + creditUsagePercent: 35, + billingPeriodEnd: '2026-07-20T00:00:00.000Z', + }, + }); + const extension = setup({ auth: authenticatedAccounts(), preserveHome: true }); + + const snapshot = extension.accountManagement.manager.snapshot( + extension.context as unknown as ExtensionContext, + ); + + expect(snapshot.accounts[1]).toMatchObject({ + provider: 'grok-cli-2', + label: 'Work', + authenticated: true, + active: false, + environment: false, + plan: 'supergrok-lite', + quota: { + monthly: { monthlyLimit: 2000, used: 700 }, + weekly: { creditUsagePercent: 35 }, + }, + }); + expect(JSON.stringify(snapshot)).not.toContain('work-token'); + }); + + it('reads authentication through the Pi 0.80.9 model-registry facade', () => { + configureAccounts(); + const extension = setup({ auth: authenticatedAccounts(), preserveHome: true }); + const modelRegistry = extension.context.modelRegistry as unknown as { + authStorage?: unknown; + getProviderAuthStatus?: (provider: string) => { configured: boolean }; + }; + delete modelRegistry.authStorage; + modelRegistry.getProviderAuthStatus = (provider) => ({ + configured: provider === 'grok-cli' || provider === 'grok-cli-2', + }); + + expect( + extension.accountManagement.manager.snapshot(extension.context as unknown as ExtensionContext) + .accounts, + ).toEqual([ + expect.objectContaining({ provider: 'grok-cli', authenticated: true }), + expect.objectContaining({ provider: 'grok-cli-2', authenticated: true }), + ]); + }); + + it('shares add and rename mutations with alternate interfaces', async () => { + const extension = setup(); + + const account = await extension.accountManagement.manager.add( + extension.context as unknown as ExtensionContext, + ' Work ', + ); + await extension.accountManagement.manager.rename( + extension.context as unknown as ExtensionContext, + account.provider, + 'Client', + ); + + expect(account.provider).toBe('grok-cli-2'); + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Account 1' }, + { provider: 'grok-cli-2', label: 'Client' }, + ]); + expect(extension.registerAccount).toHaveBeenNthCalledWith(1, { + provider: 'grok-cli-2', + label: 'Work', + }); + expect(extension.registerAccount).toHaveBeenNthCalledWith(2, { + provider: 'grok-cli-2', + label: 'Client', + }); + }); + + it('shows cached quota usage in both account selectors', async () => { + configureAccounts(); + await saveQuotaUsage( + 'grok-cli', + { + monthly: { + monthlyLimit: 2000, + used: 300, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + weekly: { + creditUsagePercent: 60, + billingPeriodEnd: '2026-07-20T00:00:00.000Z', + }, + }, + new Date().toISOString(), + ); + const extension = setup({ + auth: authenticatedAccounts(), + preserveHome: true, + selections: ['Manage accounts', 'Work — Authenticated', 'Back'], + }); + + await runAccountsCommand(extension); + + expect(extension.context.ui.custom).toHaveBeenCalledTimes(2); + expect(extension.customRenders[0]?.join('\n')).toContain( + 'Monthly 300 / 2,000 used · Weekly 60% used', + ); + expect(extension.customRenders.at(-1)?.join('\n')).toContain('Quota not fetched · press r'); + }); + + it('refreshes every logged-in account with r without changing models and preserves selection', async () => { + configureAccounts(); + process.env.GROK_CLI_OAUTH_TOKEN = 'personal'; + const timeout = vi.spyOn(AbortSignal, 'timeout'); + const fetchMock = vi.fn(async (input, init) => { + const authorization = (init?.headers as Record).authorization; + const used = authorization === 'Bearer personal' ? 300 : 900; + if (String(input).includes('format=credits')) { + return Response.json({ + config: { + currentPeriod: { type: 'USAGE_PERIOD_TYPE_WEEKLY' }, + creditUsagePercent: used === 300 ? 60 : 25, + billingPeriodEnd: '2026-07-20T00:00:00.000Z', + }, + }); + } + return Response.json({ + config: { + monthlyLimit: { val: 2000 }, + used: { val: used }, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + }); + globalThis.fetch = fetchMock; + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + preserveHome: true, + customActions: [ + async (component) => { + component.handleInput?.('\u001b[B'); + component.handleInput?.('r'); + component.handleInput?.('r'); + await vi.waitFor(() => { + expect(Object.keys(loadQuotaCache().accounts)).toHaveLength(2); + }); + expect(component.render(160).find((line) => line.startsWith('→ '))).toContain('Work'); + component.handleInput?.('\u001b'); + }, + ], + }); + + await runAccountsCommand(extension); + + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(timeout).toHaveBeenCalledTimes(2); + expect(timeout).toHaveBeenCalledWith(30_000); + expect(loadQuotaCache().accounts['grok-cli']?.monthly.used).toBe(300); + expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(900); + expect(extension.setModel).not.toHaveBeenCalled(); + expect(extension.customRenders.flat().join('\n')).toContain('Updated 2 accounts; 0 failed'); + }); + + it('keeps cached quota on a partial refresh failure and marks only that row failed', async () => { + configureAccounts(); + await saveQuotaUsage( + 'grok-cli-2', + { + monthly: { + monthlyLimit: 2000, + used: 700, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }, + new Date().toISOString(), + ); + globalThis.fetch = vi.fn(async (input, init) => { + const authorization = (init?.headers as Record).authorization; + if (authorization === 'Bearer work') return new Response('nope', { status: 500 }); + if (String(input).includes('format=credits')) + return new Response('no weekly', { status: 500 }); + return Response.json({ + config: { + monthlyLimit: { val: 2000 }, + used: { val: 300 }, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + }); + const extension = setup({ + auth: authenticatedAccounts(), + preserveHome: true, + customActions: [ + async (component) => { + component.handleInput?.('r'); + await vi.waitFor(() => { + expect(component.render(160).join('\n')).toContain('Updated 1 accounts; 1 failed'); + }); + expect(component.render(160).join('\n')).toContain( + 'Monthly 700 / 2,000 used · Weekly unavailable', + ); + expect(component.render(160).join('\n')).toContain('refresh failed'); + component.handleInput?.('\u001b'); + }, + ], + }); + + await runAccountsCommand(extension); + + expect(loadQuotaCache().accounts['grok-cli']?.monthly.used).toBe(300); + expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(700); + }); + + it('allows removal during refresh without restoring the removed quota cache', async () => { + configureAccounts(); + let releaseMonthly = () => {}; + globalThis.fetch = vi.fn(async (input) => { + if (String(input).includes('format=credits')) { + return new Response('no weekly', { status: 500 }); + } + await new Promise((resolve) => { + releaseMonthly = resolve; + }); + return Response.json({ + config: { + monthlyLimit: { val: 2000 }, + used: { val: 900 }, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + }); + const extension = setup({ auth: authenticatedAccounts(), preserveHome: true }); + const refresh = extension.accountManagement.manager.refreshOne( + extension.context as unknown as ExtensionContext, + 'grok-cli-2', + new AbortController().signal, + ); + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1)); + const removal = extension.accountManagement.manager.remove( + extension.context as unknown as ExtensionContext, + 'grok-cli-2', + ); + + await removal; + await extension.accountManagement.manager.add( + extension.context as unknown as ExtensionContext, + 'Replacement', + ); + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Replacement' }, + ]); + releaseMonthly(); + await refresh; + + expect(loadQuotaCache().accounts['grok-cli-2']).toBeUndefined(); + }); + + it('allows logout during refresh without restoring the logged-out quota cache', async () => { + configureAccounts(); + let releaseMonthly = () => {}; + globalThis.fetch = vi.fn(async (input) => { + if (String(input).includes('format=credits')) return new Response(null, { status: 500 }); + await new Promise((resolve) => { + releaseMonthly = resolve; + }); + return Response.json({ + config: { + monthlyLimit: { val: 2000 }, + used: { val: 900 }, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + }); + const extension = setup({ auth: authenticatedAccounts(), preserveHome: true }); + const refresh = extension.accountManagement.manager.refreshOne( + extension.context as unknown as ExtensionContext, + 'grok-cli', + new AbortController().signal, + ); + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1)); + + await extension.accountManagement.manager.logout( + extension.context as unknown as ExtensionContext, + 'grok-cli', + ); + releaseMonthly(); + await refresh; + + expect(loadQuotaCache().accounts['grok-cli']).toBeUndefined(); + }); + + it('handles uppercase R with no authenticated accounts without fetching', async () => { + configureAccounts(); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + const extension = setup({ + preserveHome: true, + customActions: [ + async (component) => { + component.handleInput?.('R'); + expect(component.render(35).join('\n')).toContain('No logged-in accounts to refresh'); + expect(component.render(35).join('\n')).toContain('Personal — Login required'); + component.handleInput?.('\u001b'); + }, + ], + }); + + await runAccountsCommand(extension); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('aborts outstanding quota requests when the selector closes', async () => { + const accounts = numberedAccounts(5); + configureAccounts('grok-cli', accounts, 6); + const aborted = vi.fn(); + globalThis.fetch = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + aborted(); + reject(new DOMException('Aborted', 'AbortError')); + }); + }), + ); + const extension = setupRefresh(accounts, async (component) => { + component.handleInput?.('r'); + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(3)); + component.handleInput?.('\u001b'); + }); + + await runAccountsCommand(extension); + await vi.waitFor(() => expect(aborted).toHaveBeenCalledTimes(3)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(globalThis.fetch).toHaveBeenCalledTimes(3); + expect(loadQuotaCache().accounts).toEqual({}); + }); + + it('runs at most three account refreshes concurrently', async () => { + const accounts = numberedAccounts(5); + configureAccounts('grok-cli', accounts, 6); + const releases: (() => void)[] = []; + let active = 0; + let maximum = 0; + globalThis.fetch = vi.fn(async (input) => { + if (String(input).includes('format=credits')) + return new Response('no weekly', { status: 500 }); + active += 1; + maximum = Math.max(maximum, active); + await new Promise((resolve) => releases.push(resolve)); + active -= 1; + return Response.json({ + config: { + monthlyLimit: { val: 2000 }, + used: { val: 300 }, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + }); + const extension = setupRefresh(accounts, async (component) => { + component.handleInput?.('r'); + await vi.waitFor(() => expect(releases).toHaveLength(3)); + expect(maximum).toBe(3); + releases.splice(0).forEach((release) => { + release(); + }); + await vi.waitFor(() => expect(releases).toHaveLength(2)); + releases.splice(0).forEach((release) => { + release(); + }); + await vi.waitFor(() => expect(Object.keys(loadQuotaCache().accounts)).toHaveLength(5)); + component.handleInput?.('\u001b'); + }); + + await runAccountsCommand(extension); + + expect(maximum).toBe(3); + }); + + it('adds a labeled alias and pre-fills Pi native login', async () => { + const extension = setup({ selections: ['+ Add account'], inputs: ['Work'] }); + + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts).toEqual({ + nextAccountNumber: 3, + selectedProvider: 'grok-cli', + items: [ + { provider: 'grok-cli', label: 'Account 1' }, + { provider: 'grok-cli-2', label: 'Work' }, + ], + }); + expect(extension.registerAccount).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + label: 'Work', + }); + expect(extension.setEditorText).toHaveBeenCalledWith('/login grok-cli-2'); + }); + + it('uses the lowest available alias number and its matching default label', async () => { + configureAccounts('grok-cli', [{ provider: 'grok-cli', label: 'Account 1' }], 4); + const extension = setup({ preserveHome: true, selections: ['+ Add account'], inputs: [''] }); + + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-2', + label: 'Account 2', + }); + expect(loadConfig().config.accounts.nextAccountNumber).toBe(3); + }); + + it('switches a logged-in account while preserving the current Grok model', async () => { + configureAccounts(); + const extension = setup({ + auth: { + 'grok-cli': oauthCredential('personal'), + 'grok-cli-2': oauthCredential('work'), + }, + model: { provider: 'grok-cli', id: 'grok-composer-2.5-fast' }, + preserveHome: true, + selections: ['Work — Authenticated'], + }); + + await runAccountsCommand(extension); + + expect(extension.setModel).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + id: 'grok-composer-2.5-fast', + }); + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + }); + + it('uses grok-build when switching from a non-Grok model', async () => { + configureAccounts(); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + model: { provider: 'openai', id: 'gpt-5' }, + preserveHome: true, + selections: ['Work — Authenticated'], + }); + + await runAccountsCommand(extension); + + expect(extension.setModel).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + id: 'grok-build', + }); + }); + + it('prefills login instead of switching an unauthenticated account', async () => { + configureAccounts(); + const extension = setup({ preserveHome: true, selections: ['Work — Login required'] }); + + await runAccountsCommand(extension); + + expect(extension.setEditorText).toHaveBeenCalledWith('/login grok-cli-2'); + expect(extension.setModel).not.toHaveBeenCalled(); + }); + + it('does not persist a switch when Pi rejects the model change', async () => { + configureAccounts(); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + preserveHome: true, + selections: ['Work — Authenticated'], + setModel: [false], + }); + + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli'); + expect(extension.notify).toHaveBeenCalledWith( + 'Could not switch to “Work”; authentication is unavailable.', + 'error', + ); + }); + + it('rejects duplicate, controlled, and overlong labels before adding an account', async () => { + configureAccounts(); + const extension = setup({ + preserveHome: true, + selections: ['+ Add account'], + inputs: [' work ', 'bad\nlabel', 'bad\u009blabel', 'x'.repeat(41), 'Client'], + }); + + await runAccountsCommand(extension); + + expect(extension.notify.mock.calls.map(([message]) => message)).toEqual([ + 'An account named “work” already exists.', + 'Account labels cannot contain control characters.', + 'Account labels cannot contain control characters.', + 'Account labels must be 40 characters or fewer.', + ]); + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-3', + label: 'Client', + }); + }); + + it('renames an account and updates its provider display registration', async () => { + configureAccounts(); + await saveQuotaUsage('grok-cli-2', { + monthly: { + monthlyLimit: 2000, + used: 300, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + const extension = setup({ + selections: ['Manage accounts', 'Work — Login required', 'Rename'], + inputs: ['Client'], + preserveHome: true, + }); + + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.items[1]?.label).toBe('Client'); + expect(extension.registerAccount).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + label: 'Client', + }); + expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(300); + }); + + it('logs out and removes an inactive alias after confirmation', async () => { + configureAccounts(); + await saveQuotaUsage('grok-cli-2', { + monthly: { + monthlyLimit: 2000, + used: 300, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + const extension = setup({ + auth: { + 'grok-cli': oauthCredential('personal'), + 'grok-cli-2': oauthCredential('work'), + }, + confirms: [true], + model: { provider: 'grok-cli', id: 'grok-build' }, + preserveHome: true, + selections: ['Manage accounts', 'Work — Authenticated', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.authStorage.has('grok-cli-2')).toBe(false); + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Personal' }, + ]); + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); + expect(loadQuotaCache().accounts['grok-cli-2']).toBeUndefined(); + }); + + it('reuses grok-cli-2 for the next account after removing that alias', async () => { + configureAccounts(); + const extension = setup({ + auth: authenticatedAccounts(), + confirms: [true], + inputs: ['Replacement'], + model: { provider: 'grok-cli', id: 'grok-build' }, + preserveHome: true, + selections: [ + 'Manage accounts', + 'Work — Authenticated', + 'Log out and remove', + '+ Add account', + ], + }); + + await runAccountsCommand(extension); + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Replacement' }, + ]); + expect(extension.registerAccount).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + label: 'Replacement', + }); + expect(extension.setEditorText).toHaveBeenCalledWith('/login grok-cli-2'); + }); + + it('does not reuse an active alias until its deferred unregister completes', async () => { + configureAccounts('grok-cli-2'); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + confirms: [true], + inputs: ['Temporary', 'Replacement'], + model: { provider: 'grok-cli-2', id: 'grok-build' }, + preserveHome: true, + selections: [ + 'Manage accounts', + 'Work — Active', + 'Log out and remove', + '+ Add account', + '+ Add account', + ], + }); + + await runAccountsCommand(extension); + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-3', + label: 'Temporary', + }); + + extension.accountManagement.handleModelSelect({ + model: { provider: 'openai' }, + previousModel: { provider: 'grok-cli-2' }, + }); + await runAccountsCommand(extension); + + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-2', + label: 'Replacement', + }); + expect(extension.setEditorText.mock.calls.map(([value]) => value)).toEqual([ + '/login grok-cli-3', + '/login grok-cli-2', + ]); + }); + + it('leaves an alias untouched when removal confirmation is cancelled', async () => { + configureAccounts(); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + confirms: [false], + preserveHome: true, + selections: ['Manage accounts', 'Work — Authenticated', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.authStorage.has('grok-cli-2')).toBe(true); + expect(loadConfig().config.accounts.items).toHaveLength(2); + expect(extension.unregisterProvider).not.toHaveBeenCalled(); + }); + + it('switches away before removing the active alias', async () => { + configureAccounts('grok-cli-2'); + const extension = setup({ + auth: { + 'grok-cli': oauthCredential('personal'), + 'grok-cli-2': oauthCredential('work'), + }, + confirms: [true], + model: { provider: 'grok-cli-2', id: 'grok-composer-2.5-fast' }, + preserveHome: true, + selections: ['Manage accounts', 'Work — Active', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.setModel).toHaveBeenCalledWith({ + provider: 'grok-cli', + id: 'grok-composer-2.5-fast', + }); + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli'); + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); + }); + + it('does not switch a non-Grok model when removing the selected alias', async () => { + configureAccounts('grok-cli-2'); + const extension = setup({ + auth: authenticatedAccounts(), + confirms: [true], + model: { provider: 'openai', id: 'gpt-5' }, + preserveHome: true, + selections: ['Manage accounts', 'Work — Active', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.setModel).not.toHaveBeenCalled(); + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli'); + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); + }); + + it('keeps the selected account when removing another inactive alias', async () => { + configureAccounts( + 'grok-cli-2', + [...TEST_ACCOUNTS, { provider: 'grok-cli-3', label: 'Client' }], + 4, + ); + const extension = setup({ + auth: { + ...authenticatedAccounts(), + 'grok-cli-3': oauthCredential('client'), + }, + confirms: [true], + model: { provider: 'openai', id: 'gpt-5' }, + preserveHome: true, + selections: ['Manage accounts', 'Client — Authenticated', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.setModel).not.toHaveBeenCalled(); + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-3'); + }); + + it('defers unregistering an active alias when no authenticated fallback exists', async () => { + configureAccounts('grok-cli-2'); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + confirms: [true], + model: { provider: 'grok-cli-2', id: 'grok-build' }, + preserveHome: true, + selections: ['Manage accounts', 'Work — Active', 'Log out and remove'], + }); + + await runAccountsCommand(extension); + + expect(extension.unregisterProvider).not.toHaveBeenCalled(); + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Personal' }, + ]); + + extension.accountManagement.handleModelSelect({ + model: { provider: 'openai' }, + previousModel: { provider: 'grok-cli-2' }, + }); + + expect(extension.unregisterProvider).toHaveBeenCalledWith('grok-cli-2'); + }); + + it('prefills native Pi relogin from account management', async () => { + configureAccounts(); + const extension = setup({ + auth: { 'grok-cli-2': oauthCredential('work') }, + preserveHome: true, + selections: ['Manage accounts', 'Work — Authenticated', 'Log in again'], + }); + + await runAccountsCommand(extension); + + expect(extension.setEditorText).toHaveBeenCalledWith('/login grok-cli-2'); + }); + + it('keeps the base slot and resets its label when logging out', async () => { + configureAccounts('grok-cli', [{ provider: 'grok-cli', label: 'Personal' }], 2); + await saveQuotaUsage('grok-cli', { + monthly: { + monthlyLimit: 2000, + used: 300, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + const extension = setup({ + auth: { 'grok-cli': oauthCredential('personal') }, + confirms: [true], + preserveHome: true, + selections: ['Manage accounts', 'Personal — Active', 'Log out'], + }); + + await runAccountsCommand(extension); + + expect(extension.authStorage.has('grok-cli')).toBe(false); + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Account 1' }, + ]); + expect(extension.unregisterProvider).not.toHaveBeenCalled(); + expect(loadQuotaCache().accounts['grok-cli']).toBeUndefined(); + }); + + it('keeps the selected alias when logging out of the inactive base account', async () => { + configureAccounts('grok-cli-2'); + const extension = setup({ + auth: { + 'grok-cli': oauthCredential('personal'), + 'grok-cli-2': oauthCredential('work'), + }, + confirms: [true], + preserveHome: true, + selections: ['Manage accounts', 'Personal — Authenticated', 'Log out'], + }); + + await runAccountsCommand(extension); + + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + expect(loadConfig().config.accounts.items[0]?.label).toBe('Account 1'); + }); + + it('explains that a base environment token cannot be logged out from Pi', async () => { + const original = process.env.GROK_CLI_OAUTH_TOKEN; + process.env.GROK_CLI_OAUTH_TOKEN = 'environment-token'; + try { + const extension = setup({ + selections: [ + 'Manage accounts', + 'Account 1 — Active (environment)', + 'Environment token instructions', + ], + }); + + await runAccountsCommand(extension); + + expect(extension.notify).toHaveBeenCalledWith( + 'Unset GROK_CLI_OAUTH_TOKEN and restart Pi to remove the environment token.', + 'info', + ); + expect(extension.authStorage.has('grok-cli')).toBe(false); + } finally { + if (original === undefined) delete process.env.GROK_CLI_OAUTH_TOKEN; + else process.env.GROK_CLI_OAUTH_TOKEN = original; + } + }); + + it('persists a Grok alias selected through Pi model controls', async () => { + configureAccounts(); + const extension = setup({ preserveHome: true }); + + await extension.accountManagement.handleModelSelect({ + model: { provider: 'grok-cli-2' }, + previousModel: { provider: 'grok-cli' }, + }); + + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + }); + + it('applies model selections after an overlapping account mutation', async () => { + configureAccounts(); + const extension = setup({ auth: authenticatedAccounts(), preserveHome: true }); + let finishModelSwitch = () => {}; + extension.setModel.mockImplementationOnce( + () => + new Promise((resolve) => { + finishModelSwitch = () => resolve(true); + }), + ); + const activation = extension.accountManagement.manager.activate( + extension.context as unknown as ExtensionContext, + 'grok-cli-2', + ); + await vi.waitFor(() => expect(extension.setModel).toHaveBeenCalledTimes(1)); + + const selection = extension.accountManagement.handleModelSelect({ + model: { provider: 'grok-cli' }, + previousModel: { provider: 'grok-cli-2' }, + }); + finishModelSwitch(); + await Promise.all([activation, selection]); + + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli'); + }); +}); diff --git a/tests/provider/dashboard.test.ts b/tests/provider/dashboard.test.ts new file mode 100644 index 0000000..0a2f387 --- /dev/null +++ b/tests/provider/dashboard.test.ts @@ -0,0 +1,865 @@ +import { once } from 'node:events'; +import { createConnection } from 'node:net'; +import { + InMemoryCredentialStore, + type OAuthCredentials, + type OAuthLoginCallbacks, +} from '@earendil-works/pi-ai'; +import { + type ExtensionAPI, + type ExtensionContext, + ModelRegistry, + ModelRuntime, +} from '@earendil-works/pi-coding-agent'; +import { + type HTMLDialogElement as BrowserDialog, + type HTMLElement as BrowserElement, + type HTMLInputElement as BrowserInput, + Window, +} from 'happy-dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; +import { registerAccountManagement } from '../../src/provider/accounts.js'; +import { + type AccountDashboardHandle, + createAccountDashboard, + startAccountDashboard, +} from '../../src/provider/dashboard/server.js'; +import { oauthCredential, useTempHome } from '../vision/helpers.js'; + +const setupHome = useTempHome(); +const dashboards: AccountDashboardHandle[] = []; + +afterEach(async () => { + await Promise.all(dashboards.splice(0).map((dashboard) => dashboard.close())); + vi.restoreAllMocks(); +}); + +async function setup() { + setupHome(); + saveConfig(DEFAULT_CONFIG); + const credentials = new InMemoryCredentialStore(); + await credentials.modify('grok-cli', async () => oauthCredential('personal')); + const runtime = await ModelRuntime.create({ + credentials, + modelsPath: null, + allowModelNetwork: false, + }); + const loginFlows = new Map< + string, + (callbacks: OAuthLoginCallbacks) => Promise + >(); + const registerAccount = (account: { provider: string; label: string }) => { + runtime.registerProvider(account.provider, { + name: account.label, + baseUrl: 'https://example.test', + api: 'openai-responses', + models: [ + { + id: 'grok-build', + name: 'Grok Build', + reasoning: true, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + ], + oauth: { + name: account.label, + usesCallbackServer: true, + login: (callbacks) => + loginFlows.get(account.provider)?.(callbacks) ?? + Promise.reject(new Error('No test login flow registered.')), + refreshToken: async (credential) => credential, + getApiKey: (credential) => credential.access, + }, + }); + }; + registerAccount({ provider: 'grok-cli', label: 'Account 1' }); + const modelRegistry = new ModelRegistry(runtime); + const pi = { + registerCommand: vi.fn(), + setModel: vi.fn(async () => true), + unregisterProvider: vi.fn((provider: string) => runtime.unregisterProvider(provider)), + } as unknown as ExtensionAPI; + const accountManagement = registerAccountManagement(pi, registerAccount); + const ctx = { + model: { provider: 'grok-cli', id: 'grok-build' }, + modelRegistry, + ui: { notify: vi.fn() }, + } as unknown as ExtensionContext; + return { + accountManagement, + credentials, + ctx, + loginFlows, + pi, + runtime, + async setCredential(provider: string, credential: ReturnType) { + await credentials.modify(provider, async () => credential); + await runtime.refresh({ allowNetwork: false }); + }, + }; +} + +async function bootstrap(dashboard: AccountDashboardHandle) { + const response = await fetch(dashboard.bootstrapUrl, { redirect: 'manual' }); + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('/'); + return response.headers.get('set-cookie')?.split(';')[0] ?? ''; +} + +const mutationHeaders = (dashboard: AccountDashboardHandle, cookie: string) => ({ + Cookie: cookie, + Origin: dashboard.origin, + 'Content-Type': 'application/json', + 'X-Grok-CSRF': dashboard.csrfToken, +}); + +const servedFile = (session: Awaited>, path = '') => + fetch(`${session.dashboard.origin}${path}`, { headers: { Cookie: session.cookie } }).then( + (response) => response.text(), + ); + +const accountState = ( + overrides: Record = {}, + login: Record = { state: 'idle' }, +) => ({ + provider: 'grok-cli', + label: 'Account 1', + status: 'Logged in', + authenticated: true, + active: true, + environment: false, + login, + ...overrides, +}); + +async function browserDashboard( + session: Awaited>, + states: { refreshing: boolean; accounts: ReturnType[] }[], + mutate: (path: string, method: string, body: Record) => unknown = () => ({}), + webgl = false, +) { + const [page, styles, script] = await Promise.all([ + servedFile(session), + servedFile(session, '/app.css'), + servedFile(session, '/app.js'), + ]); + const window = new Window({ url: session.dashboard.origin }); + const timers = new Map(); + const frames = new Map(); + const resizeCallbacks: ResizeObserverCallback[] = []; + let timerId = 0; + let frameId = 0; + let stateIndex = 0; + let canvasWidth = 800; + let canvasHeight = 600; + const drawArrays = vi.fn(); + const viewport = vi.fn(); + const animations = vi.fn(() => ({ cancel() {} })); + + Object.assign(window, { + fetch: vi.fn(async (input: RequestInfo | URL, options: RequestInit = {}) => { + const url = new URL(String(input), session.dashboard.origin); + if (url.pathname === '/api/state') { + const state = states[Math.min(stateIndex, states.length - 1)]; + stateIndex += 1; + return Response.json(state); + } + return Response.json( + mutate( + url.pathname, + options.method ?? 'GET', + options.body ? JSON.parse(String(options.body)) : {}, + ), + ); + }), + matchMedia: () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }), + open: () => window, + requestAnimationFrame: (callback: FrameRequestCallback) => { + frameId += 1; + frames.set(frameId, callback); + return frameId; + }, + cancelAnimationFrame: (id: number) => frames.delete(id), + ResizeObserver: class { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.push(callback); + } + observe() {} + unobserve() {} + disconnect() {} + }, + setTimeout: (callback: TimerHandler, delay = 0) => { + timerId += 1; + timers.set(timerId, { callback, delay }); + return timerId; + }, + clearTimeout: (id: number) => timers.delete(id), + }); + Object.defineProperties(window.HTMLCanvasElement.prototype, { + clientWidth: { configurable: true, get: () => canvasWidth }, + clientHeight: { configurable: true, get: () => canvasHeight }, + }); + Object.defineProperty(window.performance, 'now', { configurable: true, value: () => 0 }); + window.HTMLCanvasElement.prototype.getContext = vi.fn(() => + webgl + ? { + ARRAY_BUFFER: 1, + COMPILE_STATUS: 1, + FLOAT: 1, + FRAGMENT_SHADER: 1, + LINK_STATUS: 1, + STATIC_DRAW: 1, + TRIANGLES: 1, + VERTEX_SHADER: 1, + attachShader() {}, + bindBuffer() {}, + bufferData() {}, + compileShader() {}, + createBuffer: () => ({}), + createProgram: () => ({}), + createShader: () => ({}), + drawArrays, + enableVertexAttribArray() {}, + getProgramParameter: () => true, + getShaderParameter: () => true, + getUniformLocation: () => ({}), + linkProgram() {}, + shaderSource() {}, + uniform1f() {}, + uniform2f() {}, + useProgram() {}, + vertexAttribPointer() {}, + viewport, + } + : null, + ) as typeof window.HTMLCanvasElement.prototype.getContext; + Object.defineProperty(window.Element.prototype, 'animate', { + configurable: true, + value: animations, + }); + window.HTMLDialogElement.prototype.showModal = function () { + this.open = true; + }; + window.HTMLDialogElement.prototype.close = function (returnValue = '') { + this.returnValue = returnValue; + this.open = false; + this.dispatchEvent(new window.Event('close')); + }; + window.document.write( + page + .replace('', ``) + .replace('', ''), + ); + window.eval(script); + await vi.waitFor(() => expect(window.document.querySelector('.account-card')).not.toBeNull()); + + const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + return { + animations, + drawArrays, + resizeCallbacks, + viewport, + window, + async close() { + await window.happyDOM.abort(); + }, + async runFrame(now: number) { + const next = frames.entries().next().value as [number, FrameRequestCallback] | undefined; + if (!next) return; + frames.delete(next[0]); + next[1](now); + await flush(); + }, + async runTimers(delay: number) { + const pending = [...timers].filter(([, timer]) => timer.delay === delay); + for (const [id, timer] of pending) { + timers.delete(id); + if (typeof timer.callback === 'function') timer.callback(); + } + await flush(); + }, + resize(width: number, height: number) { + canvasWidth = width; + canvasHeight = height; + for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); + }, + }; +} + +async function openDashboard(options?: Parameters[2]) { + const extension = await setup(); + const dashboard = await startAccountDashboard( + extension.accountManagement.manager, + extension.ctx, + options, + ); + dashboards.push(dashboard); + const cookie = await bootstrap(dashboard); + return { + extension, + dashboard, + cookie, + headers: mutationHeaders(dashboard, cookie), + }; +} + +async function openIncompleteMutation(session: Awaited>) { + const url = new URL(session.dashboard.origin); + const socket = createConnection({ host: url.hostname, port: Number(url.port) }); + await once(socket, 'connect'); + socket.write( + [ + 'POST /api/accounts HTTP/1.1', + `Host: ${url.host}`, + `Cookie: ${session.cookie}`, + `Origin: ${session.dashboard.origin}`, + 'Content-Type: application/json', + `X-Grok-CSRF: ${session.dashboard.csrfToken}`, + 'Content-Length: 100', + '', + '{', + ].join('\r\n'), + ); + return socket; +} + +async function waitForAccount( + dashboard: AccountDashboardHandle, + cookie: string, + provider: string, + predicate: (account: Record) => boolean, +) { + await vi.waitFor(async () => { + const state = (await ( + await fetch(`${dashboard.origin}/api/state`, { headers: { Cookie: cookie } }) + ).json()) as { accounts: Record[] }; + expect(predicate(state.accounts.find((account) => account.provider === provider) ?? {})).toBe( + true, + ); + }); +} + +describe('account dashboard loopback server', () => { + it('keeps simultaneous dashboard sessions isolated by cookie name', async () => { + const first = await openDashboard(); + const second = await openDashboard(); + const cookies = `${first.cookie}; ${second.cookie}`; + + expect(first.cookie.split('=')[0]).not.toBe(second.cookie.split('=')[0]); + expect( + (await fetch(`${first.dashboard.origin}/api/state`, { headers: { Cookie: cookies } })).status, + ).toBe(200); + expect( + (await fetch(`${second.dashboard.origin}/api/state`, { headers: { Cookie: cookies } })) + .status, + ).toBe(200); + }); + + it('requires its capability cookie and serves credential-free state with strict headers', async () => { + const session = await openDashboard({ refreshAfterLogin: false }); + + expect((await fetch(`${session.dashboard.origin}/api/state`)).status).toBe(401); + const htmlError = await fetch(`${session.dashboard.origin}/`, { + headers: { Accept: 'text/html' }, + }); + expect(htmlError.status).toBe(401); + expect(htmlError.headers.get('content-type')).toContain('text/html'); + expect(await htmlError.text()).toContain('/grok-cli-accounts gui'); + const page = await fetch(session.dashboard.origin, { + headers: { Cookie: session.cookie }, + }); + const state = await fetch(`${session.dashboard.origin}/api/state`, { + headers: { Cookie: session.cookie }, + }); + + expect(page.status).toBe(200); + expect(page.headers.get('content-security-policy')).toContain("default-src 'self'"); + expect(page.headers.get('cache-control')).toBe('no-store'); + expect(await page.text()).toContain('Pi Grok CLI'); + expect(await state.json()).toMatchObject({ + accounts: [ + { + provider: 'grok-cli', + label: 'Account 1', + authenticated: true, + active: true, + }, + ], + }); + expect( + JSON.stringify( + await ( + await fetch(`${session.dashboard.origin}/api/state`, { + headers: { Cookie: session.cookie }, + }) + ).json(), + ), + ).not.toContain('personal'); + }); + + it('validates origin and csrf before applying account mutations', async () => { + const session = await openDashboard(); + + expect( + ( + await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: { ...session.headers, Origin: 'https://evil.example' }, + body: JSON.stringify({ label: 'Work' }), + }) + ).status, + ).toBe(403); + expect( + ( + await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: { ...session.headers, 'X-Grok-CSRF': 'wrong' }, + body: JSON.stringify({ label: 'Work' }), + }) + ).status, + ).toBe(403); + + const added = await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ label: ' Work ' }), + }); + + expect(added.status).toBe(201); + expect(await added.json()).toMatchObject({ provider: 'grok-cli-2', label: 'Work' }); + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-2', + label: 'Work', + }); + }); + + it('accepts account routes for two-digit provider aliases', async () => { + const session = await openDashboard(); + for (let account = 2; account <= 10; account += 1) { + await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ label: `Account ${account}` }), + }); + } + + const renamed = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-10`, { + method: 'PATCH', + headers: session.headers, + body: JSON.stringify({ label: 'Account ten' }), + }); + + expect(renamed.status).toBe(200); + expect(loadConfig().config.accounts.items.at(-1)).toEqual({ + provider: 'grok-cli-10', + label: 'Account ten', + }); + }); + + it('renames, activates, logs out, and removes accounts through the shared manager', async () => { + const session = await openDashboard(); + await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ label: 'Work' }), + }); + await session.extension.setCredential('grok-cli-2', oauthCredential('work')); + + const renamed = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-2`, { + method: 'PATCH', + headers: session.headers, + body: JSON.stringify({ label: 'Client' }), + }); + const activated = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-2/activate`, { + method: 'POST', + headers: session.headers, + body: '{}', + }); + const removed = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-2`, { + method: 'DELETE', + headers: session.headers, + body: '{}', + }); + const loggedOut = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli/logout`, { + method: 'POST', + headers: session.headers, + body: '{}', + }); + + expect(renamed.status).toBe(200); + expect(activated.status).toBe(200); + expect(session.extension.pi.setModel).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'grok-cli-2', id: 'grok-build' }), + ); + expect(removed.status).toBe(200); + expect(loggedOut.status).toBe(200); + expect(loadConfig().config.accounts.items).toEqual([ + { provider: 'grok-cli', label: 'Account 1' }, + ]); + expect(await session.extension.credentials.read('grok-cli')).toBeUndefined(); + }); + + it('redirects browser login without exposing credentials and accepts manual codes', async () => { + const session = await openDashboard({ refreshAfterLogin: false }); + await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ label: 'Work' }), + }); + session.extension.loginFlows.set('grok-cli-2', async (callbacks) => { + callbacks.onAuth({ url: 'https://accounts.x.ai/authorize?state=browser-state' }); + const code = await callbacks.onManualCodeInput?.(); + if (code !== 'manual-code') throw new Error('manual code rejected'); + return oauthCredential('dashboard-access'); + }); + + const ticket = await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-2/login-ticket`, { + method: 'POST', + headers: session.headers, + body: '{}', + }); + const path = ((await ticket.json()) as { path: string }).path; + const redirect = await fetch(`${session.dashboard.origin}${path}`, { + headers: { Cookie: session.cookie }, + redirect: 'manual', + }); + + expect(ticket.status).toBe(201); + expect(redirect.status).toBe(302); + expect(redirect.headers.get('location')).toBe( + 'https://accounts.x.ai/authorize?state=browser-state', + ); + await fetch(`${session.dashboard.origin}/api/accounts/grok-cli-2/login-code`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ code: 'manual-code' }), + }); + await waitForAccount( + session.dashboard, + session.cookie, + 'grok-cli-2', + (account) => account.authenticated === true, + ); + const state = await ( + await fetch(`${session.dashboard.origin}/api/state`, { + headers: { Cookie: session.cookie }, + }) + ).text(); + + expect(state).not.toContain('dashboard-access'); + expect(state).not.toContain('manual-code'); + expect(state).not.toContain('browser-state'); + }); + + it('rejects malformed or oversized mutations', async () => { + const session = await openDashboard(); + const malformed = await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: '{', + }); + const oversized = await fetch(`${session.dashboard.origin}/api/accounts`, { + method: 'POST', + headers: session.headers, + body: JSON.stringify({ label: 'x'.repeat(9000) }), + }); + + expect(malformed.status).toBe(400); + expect(oversized.status).toBe(413); + }); + + it('renders safely, preserves focused login input, and announces login progress', async () => { + const session = await openDashboard(); + const malicious = ''; + const browser = await browserDashboard(session, [ + { + refreshing: false, + accounts: [ + accountState( + { label: malicious }, + { state: 'pending', progress: 'Waiting for browser authorization…' }, + ), + ], + }, + { + refreshing: false, + accounts: [ + accountState({ label: malicious }, { state: 'pending', progress: 'Enter code' }), + ], + }, + { + refreshing: false, + accounts: [accountState({ label: malicious }, { state: 'success' })], + }, + ]); + const document = browser.window.document; + const code = document.querySelector('input[name="code"]') as unknown as BrowserInput; + + expect(document.querySelector('.card-title-row h2')?.textContent).toBe(malicious); + expect(document.querySelector('.card-title-row img')).toBeNull(); + expect(document.querySelector('#dialog-cancel')?.getAttribute('type')).toBe('button'); + expect(document.querySelector('[role="alert"]')).not.toBeNull(); + expect(document.querySelectorAll('[role="status"]')).toHaveLength(2); + expect(document.querySelector('.brand-sep')?.getAttribute('aria-hidden')).toBe('true'); + code.value = 'keep-me'; + code.focus(); + + await browser.runTimers(2000); + await vi.waitFor(() => + expect(document.querySelector('#sr-status')?.textContent).toBe('Enter code'), + ); + + const rerenderedCode = document.querySelector('input[name="code"]') as unknown as BrowserInput; + expect(rerenderedCode.value).toBe('keep-me'); + expect(document.activeElement).toBe(rerenderedCode); + expect(document.querySelector('#accounts')?.classList.contains('settled')).toBe(true); + expect(browser.animations).toHaveBeenCalledOnce(); + + await browser.runTimers(2000); + + const toast = document.querySelector('#toast') as unknown as BrowserElement; + await vi.waitFor(() => expect(toast.textContent).toBe(`Logged in ${malicious}.`)); + expect(toast.classList.contains('visible')).toBe(true); + toast.dispatchEvent(new browser.window.Event('pointerenter')); + await browser.runTimers(4800); + expect(toast.classList.contains('visible')).toBe(true); + toast.dispatchEvent(new browser.window.Event('pointerleave')); + await browser.runTimers(2500); + expect(toast.classList.contains('visible')).toBe(false); + await browser.runTimers(400); + expect(toast.textContent).toBe(''); + await browser.close(); + }); + + it('applies label containment and preserves text contrast while offline', async () => { + const session = await openDashboard(); + const browser = await browserDashboard(session, [ + { + refreshing: false, + accounts: [accountState({ label: 'A'.repeat(200) })], + }, + ]); + const document = browser.window.document; + const titleRow = document.querySelector('.card-title-row'); + const title = document.querySelector('.card-title-row h2'); + const main = document.querySelector('main'); + if (!titleRow || !title || !main) throw new Error('Dashboard content did not render.'); + + expect(browser.window.getComputedStyle(titleRow).minWidth).toBe('0'); + expect(browser.window.getComputedStyle(title).overflow).toBe('hidden'); + expect(browser.window.getComputedStyle(title).textOverflow).toBe('ellipsis'); + expect(browser.window.getComputedStyle(title).whiteSpace).toBe('nowrap'); + document.querySelector('#link-state')?.classList.add('error'); + const offlineRule = [...document.styleSheets[0].cssRules].find( + (rule) => 'selectorText' in rule && rule.selectorText === 'body:has(.link-pill.error) main', + ) as unknown as { selectorText: string; style: { filter: string } }; + expect(document.querySelector(offlineRule.selectorText)).toBe(main); + expect(offlineRule.style.filter).toBe('saturate(0.55)'); + expect(offlineRule.style.filter).not.toContain('brightness'); + await browser.close(); + }); + + it('renders free-plan monthly and weekly quotas as unavailable at zero percent', async () => { + const session = await openDashboard(); + const browser = await browserDashboard(session, [ + { + refreshing: false, + accounts: [ + accountState({ + plan: 'free', + quota: { + updatedAt: '2026-07-18T11:05:00.000Z', + fresh: true, + monthly: { + monthlyLimit: 0, + used: 0, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + weekly: { + creditUsagePercent: 0, + billingPeriodEnd: '2026-07-22T00:00:00.000Z', + }, + }, + }), + ], + }, + ]); + const meters = [...browser.window.document.querySelectorAll('[role="meter"]')]; + + expect(meters.map((meter) => meter.getAttribute('aria-valuenow'))).toEqual(['0', '0']); + expect(meters.map((meter) => meter.querySelector('.gauge-value')?.textContent)).toEqual([ + '0%', + '0%', + ]); + expect( + [...browser.window.document.querySelectorAll('.quota-meta')].map((meta) => meta.textContent), + ).toEqual(['Not available', 'Not available']); + await browser.close(); + }); + + it('confirms switch, rename, removal, and logout operations', async () => { + const session = await openDashboard(); + const cases = [ + { + button: 'Switch', + initial: accountState({ active: false, label: 'Work', provider: 'grok-cli-2' }), + next: accountState({ label: 'Work', provider: 'grok-cli-2' }), + toast: 'Switched to Work.', + }, + { + button: 'Rename', + initial: accountState({ label: 'Work', provider: 'grok-cli-2' }), + next: accountState({ label: 'Renamed', provider: 'grok-cli-2' }), + response: { label: 'Renamed' }, + value: 'Renamed', + toast: 'Renamed to Renamed.', + }, + { + button: 'Remove', + initial: accountState({ active: false, label: 'Work', provider: 'grok-cli-2' }), + next: undefined, + toast: 'Removed Work.', + }, + { + button: 'Log out', + initial: accountState({ label: 'Personal' }), + next: accountState({ + authenticated: false, + active: false, + label: 'Personal', + status: 'Login required', + }), + toast: 'Logged out Personal.', + }, + ]; + + for (const operation of cases) { + const browser = await browserDashboard( + session, + [ + { refreshing: false, accounts: [operation.initial] }, + { refreshing: false, accounts: operation.next ? [operation.next] : [] }, + ], + () => operation.response ?? {}, + ); + const button = [...browser.window.document.querySelectorAll('button')].find( + (candidate) => candidate.textContent === operation.button, + ); + button?.click(); + if (operation.value) { + const input = browser.window.document.querySelector( + '#dialog-input', + ) as unknown as BrowserInput; + input.value = operation.value; + (browser.window.document.querySelector('#action-dialog') as unknown as BrowserDialog).close( + 'confirm', + ); + } else if (operation.button === 'Remove' || operation.button === 'Log out') { + (browser.window.document.querySelector('#action-dialog') as unknown as BrowserDialog).close( + 'confirm', + ); + } + await vi.waitFor(() => + expect(browser.window.document.querySelector('#toast')?.textContent).toBe(operation.toast), + ); + await browser.close(); + } + }); + + it('throttles state-field drawing and resizes through ResizeObserver', async () => { + const session = await openDashboard(); + const browser = await browserDashboard( + session, + [{ refreshing: false, accounts: [accountState()] }], + undefined, + true, + ); + + expect(browser.resizeCallbacks).toHaveLength(1); + expect(browser.viewport).toHaveBeenLastCalledWith(0, 0, 400, 300); + await browser.runFrame(10); + await browser.runFrame(20); + expect(browser.drawArrays).not.toHaveBeenCalled(); + await browser.runFrame(40); + expect(browser.drawArrays).toHaveBeenCalledOnce(); + browser.resize(1000, 500); + expect(browser.viewport).toHaveBeenLastCalledWith(0, 0, 500, 250); + await browser.close(); + }); + + it('reuses one server, reports browser-launch failures, and closes cleanly', async () => { + const extension = await setup(); + const launchBrowser = vi.fn(async () => false); + const dashboard = createAccountDashboard(extension.accountManagement.manager, { + launchBrowser, + }); + + const first = await dashboard.open(extension.ctx); + const second = await dashboard.open(extension.ctx); + + expect(second.origin).toBe(first.origin); + expect(launchBrowser).toHaveBeenCalledTimes(2); + expect(extension.ctx.ui.notify).toHaveBeenCalledWith( + expect.stringContaining(first.bootstrapUrl), + 'warning', + ); + await dashboard.close(); + await expect(fetch(first.origin)).rejects.toThrow(); + }); + + it('times out incomplete mutation request bodies', async () => { + const session = await openDashboard({ bodyTimeoutMs: 20 }); + const socket = await openIncompleteMutation(session); + const response = await Promise.race([ + once(socket, 'data').then(([data]) => data.toString()), + new Promise((resolve) => setTimeout(() => resolve(''), 100)), + ]); + const connectionClosed = await Promise.race([ + once(socket, 'close').then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 100)), + ]); + socket.destroy(); + + expect(response).toContain('408 Request Timeout'); + expect(connectionClosed).toBe(true); + }); + + it('closes promptly with an incomplete mutation request body', async () => { + const session = await openDashboard(); + const socket = await openIncompleteMutation(session); + await new Promise((resolve) => setTimeout(resolve, 10)); + const closing = session.dashboard.close(); + const closedPromptly = await Promise.race([ + closing.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 100)), + ]); + socket.destroy(); + await closing; + + expect(closedPromptly).toBe(true); + }); + + it('expires an abandoned server after its idle timeout', async () => { + const extension = await setup(); + const dashboard = await startAccountDashboard( + extension.accountManagement.manager, + extension.ctx, + { idleMs: 20 }, + ); + dashboards.push(dashboard); + await new Promise((resolve) => setTimeout(resolve, 50)); + + await expect(fetch(dashboard.origin)).rejects.toThrow(); + }); +}); diff --git a/tests/provider/package.test.ts b/tests/provider/package.test.ts index c7461c9..67cad75 100644 --- a/tests/provider/package.test.ts +++ b/tests/provider/package.test.ts @@ -11,7 +11,13 @@ describe('npm package manifest', () => { expect(packageJson.keywords).toContain('pi-package'); expect(packageJson.pi?.extensions).toEqual(['./src/index.ts']); expect(packageJson.main).toBe('./src/index.ts'); - expect(packageJson.files).toEqual(['README.md', 'SECURITY.md', 'src', 'tsconfig.json']); + expect(packageJson.files).toEqual([ + 'CONFIGURATION.md', + 'README.md', + 'SECURITY.md', + 'src', + 'tsconfig.json', + ]); }); it('runs publish checks before packing', () => { @@ -27,10 +33,10 @@ describe('npm package manifest', () => { expect(existsSync(new URL('../../vitest.config.ts', import.meta.url))).toBe(true); }); - it('declares the Pi runtime version required by web search delegation', () => { - expect(packageJson.peerDependencies?.['@earendil-works/pi-ai']).toBe('>=0.80.0'); - expect(packageJson.peerDependencies?.['@earendil-works/pi-coding-agent']).toBe('>=0.80.0'); - expect(packageJson.peerDependencies?.['@earendil-works/pi-tui']).toBe('>=0.80.0'); + it('declares the Pi runtime version required by dashboard auth and web search', () => { + expect(packageJson.peerDependencies?.['@earendil-works/pi-ai']).toBe('>=0.80.9'); + expect(packageJson.peerDependencies?.['@earendil-works/pi-coding-agent']).toBe('>=0.80.9'); + expect(packageJson.peerDependencies?.['@earendil-works/pi-tui']).toBe('>=0.80.9'); expect(packageJson.peerDependencies?.['pi-web-access']).toBe('>=0.13.0'); expect(packageJson.dependencies?.jiti).toBeUndefined(); expect(packageJson.dependencies?.typebox).toBeUndefined(); @@ -47,7 +53,6 @@ describe('repository layout', () => { it('contains the expected domain source files', () => { expect(globSync('src/**/*.ts').sort()).toEqual([ 'src/auth/config.ts', - 'src/auth/grokCredentials.ts', 'src/auth/oauth.ts', 'src/config.ts', 'src/imagine/aspect.ts', @@ -62,12 +67,17 @@ describe('repository layout', () => { 'src/index.ts', 'src/models/catalog.ts', 'src/payload/sanitize.ts', + 'src/provider/accounts.ts', 'src/provider/billing.ts', + 'src/provider/dashboard/server.ts', + 'src/provider/quotaCache.ts', 'src/provider/register.ts', + 'src/provider/rotation.ts', 'src/provider/stream.ts', 'src/provider/toolScope.ts', 'src/provider/usage.ts', 'src/shared/errors.ts', + 'src/storage.ts', 'src/tools/files.ts', 'src/tools/glob.ts', 'src/tools/read.ts', diff --git a/tests/provider/quotaCache.test.ts b/tests/provider/quotaCache.test.ts new file mode 100644 index 0000000..c0be262 --- /dev/null +++ b/tests/provider/quotaCache.test.ts @@ -0,0 +1,132 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { BillingUsage } from '../../src/provider/billing.js'; +import { + formatCachedQuota, + isCachedQuotaFresh, + loadQuotaCache, + removeQuotaUsage, + saveQuotaUsage, +} from '../../src/provider/quotaCache.js'; +import { getQuotaCachePath } from '../../src/storage.js'; +import { useTempHome } from '../vision/helpers.js'; + +const setupHome = useTempHome(); +const usage = (used: number, weekly = 60): BillingUsage => ({ + monthly: { + monthlyLimit: 2000, + used, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + weekly: { + creditUsagePercent: weekly, + billingPeriodEnd: '2026-07-20T00:00:00.000Z', + }, +}); + +describe('Grok CLI quota cache', () => { + it('starts empty without creating a file and rejects malformed cache data', () => { + setupHome(); + + expect(loadQuotaCache()).toEqual({ version: 1, accounts: {} }); + expect(existsSync(getQuotaCachePath())).toBe(false); + + mkdirSync(dirname(getQuotaCachePath()), { recursive: true }); + writeFileSync(getQuotaCachePath(), JSON.stringify({ version: 2, accounts: {} })); + expect(loadQuotaCache()).toEqual({ version: 1, accounts: {} }); + + writeFileSync( + getQuotaCachePath(), + JSON.stringify({ + version: 1, + accounts: { + 'grok-cli': { + updatedAt: 'not-a-date', + monthly: { monthlyLimit: 2000, used: '300', billingPeriodEnd: 'bad' }, + }, + }, + }), + ); + expect(loadQuotaCache()).toEqual({ version: 1, accounts: {} }); + }); + + it('atomically stores provider usage and serializes concurrent updates', async () => { + const home = setupHome(); + const updatedAt = '2026-07-15T10:30:00.000Z'; + + await Promise.all([ + saveQuotaUsage('grok-cli', usage(300), updatedAt), + saveQuotaUsage('grok-cli-2', usage(900, 25), updatedAt), + ]); + + expect(loadQuotaCache()).toEqual({ + version: 1, + accounts: { + 'grok-cli': { updatedAt, ...usage(300) }, + 'grok-cli-2': { updatedAt, ...usage(900, 25) }, + }, + }); + expect( + JSON.parse(readFileSync(getQuotaCachePath(), 'utf8')).accounts['grok-cli-2'].monthly.used, + ).toBe(900); + expect( + existsSync(`${getQuotaCachePath()}.${process.pid}.tmp`) || + existsSync(`${getQuotaCachePath()}.tmp`), + ).toBe(false); + expect(getQuotaCachePath()).toContain(`${home}/.pi/grok-cli/`); + }); + + it('removes only the requested provider entry', async () => { + setupHome(); + await saveQuotaUsage('grok-cli', usage(300), '2026-07-15T10:30:00.000Z'); + await saveQuotaUsage('grok-cli-2', usage(900), '2026-07-15T10:30:00.000Z'); + + await removeQuotaUsage('grok-cli-2'); + + expect(Object.keys(loadQuotaCache().accounts)).toEqual(['grok-cli']); + }); + + it('runs a queued update after the previous update fails', async () => { + setupHome(); + const invalid = Object.defineProperty({}, 'monthly', { + get() { + throw new Error('invalid usage'); + }, + }) as BillingUsage; + + const failed = saveQuotaUsage('grok-cli', invalid); + const recovered = saveQuotaUsage('grok-cli-2', usage(900)); + + await expect(failed).rejects.toThrow('invalid usage'); + await expect(recovered).resolves.toBeUndefined(); + expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(900); + }); + + it('formats fresh, stale, and weekly-unavailable usage explicitly as consumed quota', () => { + expect( + formatCachedQuota( + { updatedAt: '2026-07-15T10:22:00.000Z', ...usage(300) }, + Date.parse('2026-07-15T10:30:00.000Z'), + ), + ).toBe('Monthly 300 / 2,000 used · Weekly 60% used · 8m ago'); + + expect( + formatCachedQuota( + { + updatedAt: '2026-07-15T08:30:00.000Z', + monthly: usage(300).monthly, + }, + Date.parse('2026-07-15T10:30:00.000Z'), + ), + ).toBe('Monthly 300 / 2,000 used · Weekly unavailable · stale · 2h ago'); + }); + + it('treats quota as stale exactly thirty minutes after its update', () => { + const entry = { updatedAt: '2026-07-15T10:00:00.000Z', ...usage(300) }; + + expect(isCachedQuotaFresh(entry, Date.parse('2026-07-15T10:29:59.999Z'))).toBe(true); + expect(isCachedQuotaFresh(entry, Date.parse('2026-07-15T10:30:00.000Z'))).toBe(false); + expect(formatCachedQuota(entry, Date.parse('2026-07-15T10:30:00.000Z'))).toContain('stale'); + }); +}); diff --git a/tests/provider/register.test.ts b/tests/provider/register.test.ts index 2872037..df61071 100644 --- a/tests/provider/register.test.ts +++ b/tests/provider/register.test.ts @@ -1,17 +1,27 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { Api, Model, OAuthCredentials, OAuthProviderInterface } from '@earendil-works/pi-ai'; +import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from '@earendil-works/pi-ai'; import type { ExtensionAPI, ProviderConfig } from '@earendil-works/pi-coding-agent'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; +import { loadQuotaCache, saveQuotaUsage } from '../../src/provider/quotaCache.js'; +import { getQuotaCachePath } from '../../src/storage.js'; import { GROK_SHIM_TOOL_NAMES } from '../../src/tools/register.js'; import * as webSearchDelegate from '../../src/tools/webSearchDelegate.js'; import { plainTheme as theme } from '../tools/toolTestHelpers.js'; +import { saveTestAccounts } from '../vision/helpers.js'; -const { mockPiWebAccessInstalled } = vi.hoisted(() => ({ +const { mockOauthLogin, mockPiWebAccessInstalled } = vi.hoisted(() => ({ + mockOauthLogin: vi.fn(), mockPiWebAccessInstalled: vi.fn(() => true), })); +vi.mock('../../src/auth/oauth.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, login: mockOauthLogin }; +}); + vi.mock('../../src/tools/webSearchDelegate.js', async (importOriginal) => { const actual = await importOriginal(); return { @@ -60,6 +70,12 @@ const originalToken = process.env.GROK_CLI_OAUTH_TOKEN; const tempDirs: string[] = []; beforeEach(() => { + mockOauthLogin.mockReset(); + mockOauthLogin.mockResolvedValue({ + access: 'new-access', + refresh: 'new-refresh', + expires: Date.now() + 60_000, + }); process.env.TZ = 'America/New_York'; const dir = mkdtempSync(join(tmpdir(), 'pi-grok-cli-home-')); mkdirSync(join(dir, '.pi')); @@ -94,10 +110,13 @@ async function setupExtension(initialActiveTools = ['read', 'bash'], piWebAccess const providers = new Map(); const tools = new Map(); const handlers = new Map(); + const allHandlers = new Map(); let activeTools = initialActiveTools; const setActiveTools = vi.fn((toolNames: string[]) => { activeTools = toolNames; }); + const setModel = vi.fn(async (_model: { provider: string; id: string }) => true); + const sendUserMessage = vi.fn(); const registerGrokCli = (await import('../../src/index.js')).default; registerGrokCli({ registerProvider(name: string, config: ProviderConfig) { @@ -105,6 +124,7 @@ async function setupExtension(initialActiveTools = ['read', 'bash'], piWebAccess }, on(event: string, handler: ExtensionHandler) { handlers.set(event, handler); + allHandlers.set(event, [...(allHandlers.get(event) ?? []), handler]); }, registerCommand(name: string, config: unknown) { commands.set(name, config as CommandConfig); @@ -131,6 +151,8 @@ async function setupExtension(initialActiveTools = ['read', 'bash'], piWebAccess ].map((name) => ({ name })); }, setActiveTools, + setModel, + sendUserMessage, } as unknown as ExtensionAPI); return { commands, @@ -138,6 +160,11 @@ async function setupExtension(initialActiveTools = ['read', 'bash'], piWebAccess tools, handlers, setActiveTools, + setModel, + sendUserMessage, + async emit(event: string, data: unknown, ctx: TestContext) { + for (const handler of allHandlers.get(event) ?? []) await handler(data, ctx); + }, getActiveTools: () => activeTools, replaceActiveTools(nextTools: string[]) { activeTools = [...nextTools]; @@ -345,25 +372,41 @@ describe('Grok CLI status command', () => { it('uses the registered provider token when no env token is set', async () => { delete process.env.GROK_CLI_OAUTH_TOKEN; setupHome(); + saveConfig({ + ...DEFAULT_CONFIG, + accounts: { + nextAccountNumber: 3, + selectedProvider: 'grok-cli-2', + items: [ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Work' }, + ], + }, + }); const fetchMock = vi.fn(async () => billingResponse(4000, 100, '2026-07-01T00:00:00+00:00'), ); globalThis.fetch = fetchMock; const extension = await setupExtension(); const notify = vi.fn(); + const getApiKeyForProvider = vi.fn(async () => 'provider-token'); await extension.commands.get('grok-cli-usage')?.handler([], { ...statusContext(notify), modelRegistry: { ...statusContext(notify).modelRegistry, - getApiKeyForProvider: async () => 'provider-token', + getAll: () => [{ provider: 'grok-cli-2', id: 'grok-build' }], + getApiKeyForProvider, }, }); + expect(getApiKeyForProvider).toHaveBeenCalledWith('grok-cli-2'); expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: 'Bearer provider-token', }); expect(notify.mock.calls.at(-1)?.[0]).toContain('100 / 4,000 used 3%'); + expect(loadQuotaCache().accounts['grok-cli-2']?.monthly.used).toBe(100); + expect(loadQuotaCache().accounts['grok-cli']).toBeUndefined(); }); it('does not fetch billing when no token is available', async () => { @@ -383,28 +426,33 @@ describe('Grok CLI status command', () => { ); }); - it('does not persist billing usage to the global pi config directory', async () => { + it('persists successful billing usage in the selected provider cache', async () => { process.env.GROK_CLI_OAUTH_TOKEN = 'env-token'; - const home = setupHome(); + setupHome(); + const timeout = vi.spyOn(AbortSignal, 'timeout'); globalThis.fetch = vi.fn(async () => billingResponse(4000, 1421, '2026-07-01T00:00:00+00:00'), ); const extension = await setupExtension(); await runStatus(extension); - expect(existsSync(join(home, '.pi', 'grok-cli-quota.json'))).toBe(false); + expect(loadQuotaCache().accounts['grok-cli']).toMatchObject({ + monthly: { monthlyLimit: 4000, used: 1421 }, + }); + expect(timeout).toHaveBeenCalledWith(30_000); + expect(existsSync(getQuotaCachePath())).toBe(true); }); it('rejects invalid billing payloads instead of caching NaN values', async () => { process.env.GROK_CLI_OAUTH_TOKEN = 'env-token'; - const home = setupHome(); + setupHome(); globalThis.fetch = vi.fn(async () => billingResponse('4000', 1421, '2026-07-01T00:00:00+00:00'), ); const extension = await setupExtension(); const notify = await runStatus(extension); - expect(existsSync(join(home, '.pi', 'grok-cli-quota.json'))).toBe(false); + expect(existsSync(getQuotaCachePath())).toBe(false); expect(notify.mock.calls.at(-1)?.[0]).toBe( [ ' Usage:', @@ -433,9 +481,20 @@ describe('Grok CLI status command', () => { ); }); - it('shows no billing data when refresh fails', async () => { + it('shows the selected provider cached billing data when refresh fails', async () => { process.env.GROK_CLI_OAUTH_TOKEN = 'env-token'; setupHome(); + await saveQuotaUsage( + 'grok-cli', + { + monthly: { + monthlyLimit: 4000, + used: 1421, + billingPeriodEnd: '2026-07-01T00:00:00+00:00', + }, + }, + '2026-06-30T00:00:00.000Z', + ); globalThis.fetch = vi.fn(async () => new Response('nope', { status: 500 })); const extension = await setupExtension(); const notify = await runStatus(extension); @@ -444,9 +503,8 @@ describe('Grok CLI status command', () => { 'Grok CLI billing refresh failed: billing endpoint returned 500', 'warning', ); - expect(notify.mock.calls.at(-1)?.[0]).toContain( - 'no billing data available — run /login grok-cli or set GROK_CLI_OAUTH_TOKEN', - ); + expect(notify.mock.calls.at(-1)?.[0]).toContain('cached usage from'); + expect(notify.mock.calls.at(-1)?.[0]).toContain('1,421 / 4,000 used'); }); it('warns when no Grok models are registered', async () => { @@ -522,16 +580,90 @@ describe('Grok CLI status command', () => { }); describe('Grok CLI provider registration', () => { + it('clears cached quota after a successful OAuth login', async () => { + await saveQuotaUsage('grok-cli', { + monthly: { + monthlyLimit: 2000, + used: 300, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + }); + const extension = await setupExtension(); + + await extension.providers.get('grok-cli')?.oauth?.login({} as OAuthLoginCallbacks); + + expect(mockOauthLogin).toHaveBeenCalledOnce(); + expect(loadQuotaCache().accounts['grok-cli']).toBeUndefined(); + }); + + it('makes a recently exhausted account eligible after successful OAuth login', async () => { + saveTestAccounts('grok-cli'); + const extension = await setupExtension(); + const context = { + model: { provider: 'grok-cli', id: 'grok-build' }, + modelRegistry: { + getProviderAuthStatus: (provider: string) => ({ + configured: provider.startsWith('grok-cli'), + }), + find: (provider: string, id: string) => ({ provider, id }), + getAll: () => [], + }, + ui: { notify: vi.fn() }, + }; + const exhausted = (provider: string) => ({ + type: 'message_end', + message: { + role: 'assistant', + provider, + model: 'grok-build', + stopReason: 'error', + errorMessage: 'OpenAI API error (402): 402 "Grok Build usage balance exhausted"', + }, + }); + + await extension.emit('message_end', exhausted('grok-cli'), context as TestContext); + await extension.emit('agent_settled', { type: 'agent_settled' }, context as TestContext); + context.model = { provider: 'grok-cli-2', id: 'grok-build' }; + await extension.emit( + 'input', + { type: 'input', source: 'extension', text: 'continue after rotation' }, + context as TestContext, + ); + await extension.emit( + 'message_end', + { + type: 'message_end', + message: { + role: 'assistant', + provider: 'grok-cli-2', + model: 'grok-build', + stopReason: 'stop', + }, + }, + context as TestContext, + ); + await extension.emit('agent_settled', { type: 'agent_settled' }, context as TestContext); + + await extension.providers.get('grok-cli')?.oauth?.login({} as OAuthLoginCallbacks); + await extension.emit('message_end', exhausted('grok-cli-2'), context as TestContext); + await extension.emit('agent_settled', { type: 'agent_settled' }, context as TestContext); + + expect(extension.setModel.mock.calls.map(([model]) => model.provider)).toEqual([ + 'grok-cli-2', + 'grok-cli', + ]); + }); + it('registers provider metadata and OAuth helpers', async () => { const extension = await setupExtension(); const provider = extension.providers.get('grok-cli'); - expect(provider?.name).toBe('Grok CLI'); + expect(provider?.name).toBe('Grok CLI — Account 1'); expect(provider?.api).toBe('openai-responses'); expect(provider?.apiKey).toBe('$GROK_CLI_OAUTH_TOKEN'); expect(provider?.streamSimple).toBeUndefined(); expect(provider?.models?.map((model) => model.id)).toContain('grok-build'); - expect((provider?.oauth as Omit)?.usesCallbackServer).toBe(true); + expect(provider?.oauth?.usesCallbackServer).toBe(true); expect(provider?.oauth?.getApiKey({ access: 'access-token', refresh: '', expires: 0 })).toBe( 'access-token', ); @@ -558,6 +690,41 @@ describe('Grok CLI provider registration', () => { ]); }); + it('registers configured aliases with independent OAuth keys and labeled names', async () => { + saveTestAccounts(); + + const extension = await setupExtension(); + const base = extension.providers.get('grok-cli'); + const alias = extension.providers.get('grok-cli-2'); + + expect(base?.name).toBe('Grok CLI — Personal'); + expect(base?.apiKey).toBe('$GROK_CLI_OAUTH_TOKEN'); + expect(alias?.name).toBe('Grok CLI — Work'); + expect(alias?.apiKey).toBeUndefined(); + expect(alias?.models?.map((model) => model.id)).toEqual(base?.models?.map((model) => model.id)); + expect( + alias?.oauth?.modifyModels?.( + [ + { provider: 'grok-cli', id: 'grok-build', baseUrl: 'base' } as Model, + { provider: 'grok-cli-2', id: 'grok-build', baseUrl: 'old' } as Model, + ], + { + access: 'access-token', + refresh: 'refresh-token', + expires: 123, + baseUrl: 'https://example.invalid/work///', + } as OAuthCredentials, + ), + ).toEqual([ + { provider: 'grok-cli', id: 'grok-build', baseUrl: 'base' }, + { + provider: 'grok-cli-2', + id: 'grok-build', + baseUrl: 'https://example.invalid/work', + }, + ]); + }); + it('adds conversation affinity headers only for Grok requests', async () => { const extension = await setupExtension(); const grokEvent = { headers: { existing: 'keep' } as Record }; @@ -579,6 +746,14 @@ describe('Grok CLI provider registration', () => { }); expect(openAiEvent.headers).toEqual({ existing: 'keep' }); + + const aliasEvent = { headers: {} as Record }; + extension.handlers.get('before_provider_headers')?.(aliasEvent, { + ...contextForModel('grok-cli-2'), + sessionManager: { getSessionId: () => 'session-alias' }, + }); + + expect(aliasEvent.headers).toEqual({ 'x-grok-conv-id': 'session-alias' }); }); it('sanitizes Grok provider requests with the current session id', async () => { @@ -603,6 +778,23 @@ describe('Grok CLI provider registration', () => { instructions: 'system instruction', prompt_cache_key: 'session-123', }); + + const aliasResult = extension.handlers.get('before_provider_request')?.( + { payload: { input: [{ role: 'system', content: 'alias instruction' }] } }, + { + cwd: process.cwd(), + model: { provider: 'grok-cli-2', id: 'grok-build' }, + modelRegistry: { getAll: () => [] }, + sessionManager: { getSessionId: () => 'session-alias' }, + ui: { notify: vi.fn() }, + }, + ); + + expect(aliasResult).toEqual({ + input: [], + instructions: 'alias instruction', + prompt_cache_key: 'session-alias', + }); }); it('leaves non-Grok provider requests untouched', async () => { @@ -649,7 +841,7 @@ describe('Grok CLI tool scoping', () => { await setupExtension(); - expect(existsSync(join(piDir, 'grok-cli.json'))).toBe(true); + expect(existsSync(join(piDir, 'grok-cli', 'config.json'))).toBe(true); expect(existsSync(join(piDir, 'grok-cli-imagine.json'))).toBe(false); }); @@ -706,6 +898,21 @@ describe('Grok CLI tool scoping', () => { expect(next).not.toEqual(expect.arrayContaining(['Write', 'Delete', 'Shell'])); }); + it('queues model selection persistence without blocking the event handler', async () => { + saveTestAccounts('grok-cli'); + const extension = await setupExtension(); + + const result = extension.handlers.get('model_select')?.( + { model: { provider: 'grok-cli-2', id: 'grok-build' } }, + contextForModel('grok-cli-2', 'grok-build'), + ); + + expect(result).toBeUndefined(); + await vi.waitFor(() => { + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + }); + }); + it('translates compatibility names back to native tools for non-Grok models', async () => { const extension = await setupExtension(['read', 'Grep', 'custom_tool', 'Shell']); diff --git a/tests/provider/rotation.test.ts b/tests/provider/rotation.test.ts new file mode 100644 index 0000000..3a3304e --- /dev/null +++ b/tests/provider/rotation.test.ts @@ -0,0 +1,470 @@ +import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CONFIG, loadConfig, saveConfig } from '../../src/config.js'; +import type { BillingUsage } from '../../src/provider/billing.js'; +import { saveQuotaUsage } from '../../src/provider/quotaCache.js'; +import { + EXHAUSTED_BALANCE_ERROR, + ROTATION_CONTINUATION, + registerExhaustionRotation, +} from '../../src/provider/rotation.js'; +import { useTempHome } from '../vision/helpers.js'; + +const setupHome = useTempHome(); +const THREE_ACCOUNTS = [ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Work' }, + { provider: 'grok-cli-3', label: 'Client' }, +]; +const FOUR_ACCOUNTS = [...THREE_ACCOUNTS, { provider: 'grok-cli-4', label: 'Reserve' }]; +const NOW = Date.parse('2026-07-16T12:00:00.000Z'); +const originalFetch = globalThis.fetch; + +afterEach(() => { + vi.useRealTimers(); + globalThis.fetch = originalFetch; +}); + +function usage(monthlyRemaining: number, weeklyRemaining?: number): BillingUsage { + return { + monthly: { + monthlyLimit: 100, + used: 100 - monthlyRemaining, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + ...(weeklyRemaining === undefined + ? {} + : { + weekly: { + creditUsagePercent: 100 - weeklyRemaining, + billingPeriodEnd: '2026-07-20T00:00:00.000Z', + }, + }), + }; +} + +function setup( + options: { + accounts?: { provider: string; label: string }[]; + auth?: string[]; + current?: { provider: string; id: string }; + missingModels?: string[]; + setModel?: boolean[]; + } = {}, +) { + setupHome(); + const accounts = options.accounts ?? [ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Work' }, + ]; + saveConfig({ + ...DEFAULT_CONFIG, + accounts: { + nextAccountNumber: accounts.length + 1, + selectedProvider: options.current?.provider ?? 'grok-cli', + items: accounts, + }, + }); + const handlers = new Map unknown)[]>(); + const authenticated = new Set(options.auth ?? accounts.map((account) => account.provider)); + const model = options.current ?? { provider: 'grok-cli', id: 'grok-build' }; + const context = { + model: { ...model }, + modelRegistry: { + getProviderAuthStatus: (provider: string) => ({ configured: authenticated.has(provider) }), + find: (provider: string, id: string) => + options.missingModels?.includes(`${provider}/${id}`) ? undefined : { provider, id }, + }, + ui: { notify: vi.fn() }, + }; + const setModelResults = [...(options.setModel ?? [])]; + const setModel = vi.fn(async (nextModel: { provider: string; id: string }) => { + const result = setModelResults.shift() ?? true; + if (result) context.model = nextModel; + return result; + }); + const sendUserMessage = vi.fn(); + const pi = { + on(event: string, handler: (event: unknown, ctx: unknown) => unknown) { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + sendUserMessage, + setModel, + } as unknown as ExtensionAPI; + const rotation = registerExhaustionRotation(pi); + + return { + context, + handlers, + notify: context.ui.notify, + sendUserMessage, + setModel, + rotation, + async emit(event: string, data: unknown = { type: event }) { + for (const handler of handlers.get(event) ?? []) await handler(data, context); + }, + }; +} + +function assistant( + provider: string, + errorMessage = EXHAUSTED_BALANCE_ERROR, + model = 'grok-build', + stopReason = 'error', +) { + return { + type: 'message_end', + message: { + role: 'assistant', + provider, + model, + stopReason, + errorMessage, + }, + }; +} + +async function settleExhaustion(extension: ReturnType, provider: string) { + await extension.emit('message_end', assistant(provider)); + await extension.emit('agent_settled'); +} + +async function emitExtensionContinuation(extension: ReturnType) { + await extension.emit('input', { + type: 'input', + source: 'extension', + text: ROTATION_CONTINUATION, + }); +} + +function switchedProviders(extension: ReturnType) { + return extension.setModel.mock.calls.map(([model]) => model.provider); +} + +async function setupAfterSuccessfulContinuation() { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup(); + await settleExhaustion(extension, 'grok-cli'); + await emitExtensionContinuation(extension); + await extension.emit('message_end', assistant('grok-cli-2', '', 'grok-build', 'stop')); + await extension.emit('agent_settled'); + return extension; +} + +describe('Grok CLI exhaustion rotation', () => { + it('switches only after agent_settled, preserves the model, and continues once', async () => { + const extension = setup({ current: { provider: 'grok-cli', id: 'grok-composer-2.5-fast' } }); + + await extension.emit( + 'message_end', + assistant('grok-cli', EXHAUSTED_BALANCE_ERROR, 'grok-composer-2.5-fast'), + ); + + expect(extension.setModel).not.toHaveBeenCalled(); + expect(extension.sendUserMessage).not.toHaveBeenCalled(); + + await extension.emit('agent_settled'); + + expect(extension.setModel).toHaveBeenCalledOnce(); + expect(extension.setModel).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + id: 'grok-composer-2.5-fast', + }); + expect(loadConfig().config.accounts.selectedProvider).toBe('grok-cli-2'); + expect(extension.notify).toHaveBeenCalledWith( + 'Grok CLI: “Personal” exhausted; switched to “Work” and continuing.', + 'info', + ); + expect(extension.sendUserMessage).toHaveBeenCalledOnce(); + expect(extension.sendUserMessage).toHaveBeenCalledWith(ROTATION_CONTINUATION); + }); + + it('preserves configuration changes made while switching models', async () => { + const extension = setup(); + let releaseSwitch = () => {}; + extension.setModel.mockImplementation(async (model) => { + await new Promise((resolve) => { + releaseSwitch = resolve; + }); + extension.context.model = model; + return true; + }); + await extension.emit('message_end', assistant('grok-cli')); + const settling = extension.emit('agent_settled'); + await vi.waitFor(() => expect(extension.setModel).toHaveBeenCalledOnce()); + const concurrent = loadConfig().config; + concurrent.accounts.items.push({ provider: 'grok-cli-3', label: 'Added concurrently' }); + concurrent.vision.enabled = false; + saveConfig(concurrent); + + releaseSwitch(); + await settling; + + expect(loadConfig().config).toMatchObject({ + accounts: { + selectedProvider: 'grok-cli-2', + items: expect.arrayContaining([{ provider: 'grok-cli-3', label: 'Added concurrently' }]), + }, + vision: { enabled: false }, + }); + }); + + it.each([ + ['near match', 'OpenAI API error (402): 402 "Grok Build usage balance exhausted".'], + ['other 402', 'OpenAI API error (402): payment required'], + ['401', 'OpenAI API error (401): unauthorized'], + ['429', 'OpenAI API error (429): rate limited'], + ])('ignores %s errors', async (_name, errorMessage) => { + const extension = setup(); + + await extension.emit('message_end', assistant('grok-cli', errorMessage)); + await extension.emit('agent_settled'); + + expect(extension.setModel).not.toHaveBeenCalled(); + }); + + it('ignores non-Grok providers and non-error assistant messages', async () => { + const extension = setup(); + + await extension.emit('message_end', assistant('openai', EXHAUSTED_BALANCE_ERROR)); + await extension.emit( + 'message_end', + assistant('grok-cli', EXHAUSTED_BALANCE_ERROR, 'grok-build', 'stop'), + ); + await extension.emit('agent_settled'); + + expect(extension.setModel).not.toHaveBeenCalled(); + }); + + it('uses circular account order and skips login-required accounts', async () => { + const extension = setup({ + accounts: THREE_ACCOUNTS, + auth: ['grok-cli-2', 'grok-cli-3'], + current: { provider: 'grok-cli-3', id: 'grok-composer-2.5-fast' }, + }); + + await extension.emit( + 'message_end', + assistant('grok-cli-3', EXHAUSTED_BALANCE_ERROR, 'grok-composer-2.5-fast'), + ); + await extension.emit('agent_settled'); + + expect(extension.setModel).toHaveBeenCalledWith({ + provider: 'grok-cli-2', + id: 'grok-composer-2.5-fast', + }); + }); + + it('skips failed setModel candidates and falls back to grok-build when needed', async () => { + const extension = setup({ + accounts: THREE_ACCOUNTS, + current: { provider: 'grok-cli', id: 'grok-composer-2.5-fast' }, + missingModels: ['grok-cli-3/grok-composer-2.5-fast'], + setModel: [false, true], + }); + + await extension.emit( + 'message_end', + assistant('grok-cli', EXHAUSTED_BALANCE_ERROR, 'grok-composer-2.5-fast'), + ); + await extension.emit('agent_settled'); + + expect(extension.setModel.mock.calls).toEqual([ + [{ provider: 'grok-cli-2', id: 'grok-composer-2.5-fast' }], + [{ provider: 'grok-cli-3', id: 'grok-build' }], + ]); + expect(extension.sendUserMessage).toHaveBeenCalledOnce(); + }); + + it('preserves attempted accounts across extension continuations and stops without wrapping', async () => { + const extension = setup({ + accounts: THREE_ACCOUNTS, + }); + + await settleExhaustion(extension, 'grok-cli'); + await emitExtensionContinuation(extension); + await settleExhaustion(extension, 'grok-cli-2'); + await emitExtensionContinuation(extension); + await settleExhaustion(extension, 'grok-cli-3'); + await extension.emit('agent_settled'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli-3']); + expect(extension.sendUserMessage).toHaveBeenCalledTimes(2); + expect(extension.notify).toHaveBeenCalledWith( + 'Grok CLI: all logged-in accounts are exhausted.', + 'warning', + ); + }); + + it('keeps recently exhausted accounts unavailable across new real user input', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup(); + + await settleExhaustion(extension, 'grok-cli'); + await extension.emit('input', { type: 'input', source: 'interactive', text: 'try again' }); + await settleExhaustion(extension, 'grok-cli-2'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2']); + expect(extension.notify).toHaveBeenCalledWith( + 'Grok CLI: all logged-in accounts are exhausted.', + 'warning', + ); + }); + + it('keeps recently exhausted accounts unavailable after a successful continuation', async () => { + const extension = await setupAfterSuccessfulContinuation(); + await extension.emit('message_end', assistant('grok-cli-2')); + await extension.emit('agent_settled'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2']); + }); + + it('makes an account eligible again exactly five minutes after exhaustion', async () => { + const extension = await setupAfterSuccessfulContinuation(); + vi.setSystemTime(NOW + 5 * 60_000); + await settleExhaustion(extension, 'grok-cli-2'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli']); + }); + + it('clears recent exhaustion when a new session starts', async () => { + const extension = await setupAfterSuccessfulContinuation(); + await extension.emit('session_start'); + await settleExhaustion(extension, 'grok-cli-2'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli']); + }); + + it('allows successful login to clear one account’s recent exhaustion', async () => { + const extension = await setupAfterSuccessfulContinuation(); + extension.rotation.clearRecentExhaustion('grok-cli'); + await settleExhaustion(extension, 'grok-cli-2'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli']); + }); + + it('orders fresh cached quota by the tightest remaining window without fetching', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup({ accounts: THREE_ACCOUNTS }); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + await saveQuotaUsage('grok-cli-2', usage(90, 10), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-3', usage(50), new Date(NOW).toISOString()); + + await settleExhaustion(extension, 'grok-cli'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-3']); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('continues through missing models and failed switches in quota-ranked order', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup({ + accounts: FOUR_ACCOUNTS, + current: { provider: 'grok-cli', id: 'grok-composer-2.5-fast' }, + missingModels: ['grok-cli-3/grok-composer-2.5-fast', 'grok-cli-3/grok-build'], + setModel: [false, true], + }); + await saveQuotaUsage('grok-cli-2', usage(50), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-3', usage(90), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-4', usage(70), new Date(NOW).toISOString()); + + await extension.emit( + 'message_end', + assistant('grok-cli', EXHAUSTED_BALANCE_ERROR, 'grok-composer-2.5-fast'), + ); + await extension.emit('agent_settled'); + + expect(extension.setModel.mock.calls).toEqual([ + [{ provider: 'grok-cli-4', id: 'grok-composer-2.5-fast' }], + [{ provider: 'grok-cli-2', id: 'grok-composer-2.5-fast' }], + ]); + expect(extension.sendUserMessage).toHaveBeenCalledOnce(); + }); + + it.each([ + ['missing', undefined], + ['stale', new Date(NOW - 30 * 60_000).toISOString()], + ['invalid', new Date(NOW).toISOString()], + ])('keeps a %s quota candidate in its circular slot', async (_name, updatedAt) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup({ accounts: FOUR_ACCOUNTS, setModel: [false, true] }); + if (updatedAt) { + await saveQuotaUsage( + 'grok-cli-2', + updatedAt === new Date(NOW).toISOString() + ? { + monthly: { + monthlyLimit: 0, + used: 0, + billingPeriodEnd: '2026-08-01T00:00:00.000Z', + }, + } + : usage(50), + updatedAt, + ); + } + await saveQuotaUsage('grok-cli-3', usage(20), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-4', usage(80), new Date(NOW).toISOString()); + + await settleExhaustion(extension, 'grok-cli'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2', 'grok-cli-4']); + }); + + it('preserves circular order when fresh quota scores tie', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup({ accounts: THREE_ACCOUNTS }); + await saveQuotaUsage('grok-cli-2', usage(60, 40), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-3', usage(40), new Date(NOW).toISOString()); + + await settleExhaustion(extension, 'grok-cli'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-2']); + }); + + it('excludes a recently exhausted account even when it has the best cached quota', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const extension = setup({ + accounts: THREE_ACCOUNTS, + current: { provider: 'grok-cli-2', id: 'grok-build' }, + }); + await saveQuotaUsage('grok-cli', usage(10), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-2', usage(95), new Date(NOW).toISOString()); + await saveQuotaUsage('grok-cli-3', usage(20), new Date(NOW).toISOString()); + + await settleExhaustion(extension, 'grok-cli-2'); + await emitExtensionContinuation(extension); + await extension.emit('message_end', assistant('grok-cli-3', '', 'grok-build', 'stop')); + await extension.emit('agent_settled'); + extension.context.model = { provider: 'grok-cli', id: 'grok-build' }; + await settleExhaustion(extension, 'grok-cli'); + + expect(switchedProviders(extension)).toEqual(['grok-cli-3', 'grok-cli-3']); + }); + + it('cancels a pending rotation after a manual model change', async () => { + const extension = setup(); + + await extension.emit('message_end', assistant('grok-cli')); + await extension.emit('model_select', { + type: 'model_select', + model: { provider: 'openai', id: 'gpt-5' }, + }); + await extension.emit('model_select', { + type: 'model_select', + model: { provider: 'grok-cli', id: 'grok-build' }, + }); + await extension.emit('agent_settled'); + + expect(extension.setModel).not.toHaveBeenCalled(); + expect(extension.sendUserMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/provider/toolScope.integration.test.ts b/tests/provider/toolScope.integration.test.ts index 97d6535..4c7086a 100644 --- a/tests/provider/toolScope.integration.test.ts +++ b/tests/provider/toolScope.integration.test.ts @@ -1,9 +1,8 @@ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import { fauxAssistantMessage, fauxProvider } from '@earendil-works/pi-ai'; +import { fauxAssistantMessage, fauxProvider, InMemoryCredentialStore } from '@earendil-works/pi-ai'; import { AgentSessionRuntime, - AuthStorage, type CreateAgentSessionRuntimeFactory, createAgentSession, createAgentSessionFromServices, @@ -11,6 +10,7 @@ import { DefaultResourceLoader, type ExtensionFactory, ModelRegistry, + ModelRuntime, SessionManager, SettingsManager, } from '@earendil-works/pi-coding-agent'; @@ -165,10 +165,14 @@ async function modelFacingTools(modelId: string) { provider: 'grok-cli', models: [{ id: modelId }], }); - const authStorage = AuthStorage.inMemory(); - const modelRegistry = ModelRegistry.inMemory(authStorage); + const modelRuntime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath: null, + allowModelNetwork: false, + }); + const modelRegistry = new ModelRegistry(modelRuntime); const model = faux.getModel(); - modelRegistry.registerProvider('grok-cli', { + modelRuntime.registerProvider('grok-cli', { api: faux.api, apiKey: 'local-test-key', baseUrl: model.baseUrl, @@ -196,9 +200,8 @@ async function modelFacingTools(modelId: string) { const { session } = await createAgentSession({ cwd, agentDir, - authStorage, + modelRuntime, model: modelRegistry.find('grok-cli', modelId), - modelRegistry, resourceLoader: resources.resourceLoader, sessionManager: SessionManager.inMemory(cwd), settingsManager: resources.settingsManager, @@ -209,7 +212,7 @@ async function modelFacingTools(modelId: string) { return names; } finally { session.dispose(); - modelRegistry.unregisterProvider('grok-cli'); + modelRuntime.unregisterProvider('grok-cli'); } } diff --git a/tests/provider/toolScope.test.ts b/tests/provider/toolScope.test.ts index 95fe70b..b61ac83 100644 --- a/tests/provider/toolScope.test.ts +++ b/tests/provider/toolScope.test.ts @@ -88,12 +88,13 @@ function sync( describe('syncGrokTools', () => { it.each([ - 'grok-build', - 'grok-composer-2.5-fast', - ])('uses compatibility names for exact legacy model %s', (id) => { + ['grok-cli', 'grok-build'], + ['grok-cli', 'grok-composer-2.5-fast'], + ['grok-cli-2', 'grok-build'], + ])('uses compatibility names for exact legacy model %s/%s', (provider, id) => { const state = toolState(['read', 'write', 'edit', 'grep', 'find', 'ls', 'bash']); - sync(state, 'grok-cli', id, { captureDelete: true }); + sync(state, provider, id, { captureDelete: true }); expect(state.tools()).toEqual([ 'Read', diff --git a/tests/storage.test.ts b/tests/storage.test.ts new file mode 100644 index 0000000..ba6e670 --- /dev/null +++ b/tests/storage.test.ts @@ -0,0 +1,88 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { DEFAULT_CONFIG, loadConfig, migrateLegacyConfig } from '../src/config.js'; +import { + getConfigPath, + getGrokCliDirectory, + getLegacyConfigPath, + getLegacyVisionCachePath, + getQuotaCachePath, + getVisionCachePath, +} from '../src/storage.js'; +import { getCachePath } from '../src/vision/cache.js'; +import { useTempHome } from './vision/helpers.js'; + +const setupHome = useTempHome(); + +function writeJson(path: string, value: unknown) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +describe('Grok CLI storage', () => { + it('groups extension-owned files under one directory without creating it on read', () => { + const home = setupHome(); + + expect(getGrokCliDirectory()).toBe(join(home, '.pi', 'grok-cli')); + expect(getConfigPath()).toBe(join(home, '.pi', 'grok-cli', 'config.json')); + expect(getVisionCachePath()).toBe(join(home, '.pi', 'grok-cli', 'vision-cache.json')); + expect(getQuotaCachePath()).toBe(join(home, '.pi', 'grok-cli', 'quota-cache.json')); + expect(loadConfig()).toEqual({ config: DEFAULT_CONFIG }); + expect(existsSync(getGrokCliDirectory())).toBe(false); + }); + + it('migrates the consolidated config and vision cache after verified writes', () => { + const home = setupHome(); + mkdirSync(join(home, '.pi'), { recursive: true }); + writeJson(getLegacyConfigPath(), { ...DEFAULT_CONFIG, imagine: { enabled: false } }); + writeJson(getLegacyVisionCachePath(), { + version: 1, + entries: { cached: { description: 'keep' } }, + }); + + expect(migrateLegacyConfig()).toEqual({}); + + expect(loadConfig().config.imagine.enabled).toBe(false); + expect(JSON.parse(readFileSync(getConfigPath(), 'utf8')).version).toBe(2); + expect(JSON.parse(readFileSync(getVisionCachePath(), 'utf8'))).toEqual({ + version: 1, + entries: { cached: { description: 'keep' } }, + }); + expect(existsSync(getLegacyConfigPath())).toBe(false); + expect(existsSync(getLegacyVisionCachePath())).toBe(false); + expect(getCachePath()).toBe(getVisionCachePath()); + }); + + it('keeps new files authoritative and preserves conflicting legacy files', () => { + setupHome(); + mkdirSync(getGrokCliDirectory(), { recursive: true }); + writeJson(getConfigPath(), DEFAULT_CONFIG); + writeJson(getLegacyConfigPath(), { ...DEFAULT_CONFIG, imagine: { enabled: false } }); + writeJson(getVisionCachePath(), { version: 1, entries: { current: {} } }); + writeJson(getLegacyVisionCachePath(), { version: 1, entries: { legacy: {} } }); + + const migration = migrateLegacyConfig(); + + expect(migration.warning).toContain(getLegacyConfigPath()); + expect(migration.warning).toContain(getLegacyVisionCachePath()); + expect(loadConfig().config).toEqual(DEFAULT_CONFIG); + expect(existsSync(getLegacyConfigPath())).toBe(true); + expect(existsSync(getLegacyVisionCachePath())).toBe(true); + }); + + it('falls back to legacy files when the destination directory cannot be created', () => { + const home = setupHome(); + mkdirSync(join(home, '.pi'), { recursive: true }); + writeJson(getLegacyConfigPath(), { ...DEFAULT_CONFIG, imagine: { enabled: false } }); + writeJson(getLegacyVisionCachePath(), { version: 1, entries: {} }); + writeFileSync(getGrokCliDirectory(), 'not a directory'); + + const migration = migrateLegacyConfig(); + + expect(migration.warning).toMatch(/Could not migrate/); + expect(loadConfig().config.imagine.enabled).toBe(false); + expect(getCachePath()).toBe(getLegacyVisionCachePath()); + expect(existsSync(getLegacyConfigPath())).toBe(true); + expect(existsSync(getLegacyVisionCachePath())).toBe(true); + }); +}); diff --git a/tests/vision/config.test.ts b/tests/vision/config.test.ts index dfb72b5..a8604d0 100644 --- a/tests/vision/config.test.ts +++ b/tests/vision/config.test.ts @@ -1,9 +1,10 @@ import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname } from 'node:path'; import { describe, expect, it } from 'vitest'; import { DEFAULT_CONFIG, describableModels, + getConfigPath, loadConfig, normalizeVisionConfig, saveConfig, @@ -13,7 +14,7 @@ import { useTempHome } from './helpers.js'; const setupHome = useTempHome(); function writeConfig(config: unknown) { - const configPath = join(process.env.HOME as string, '.pi', 'grok-cli.json'); + const configPath = getConfigPath(); mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, JSON.stringify({ version: 1, vision: config })); } @@ -67,8 +68,8 @@ describe('grok-cli-vision config', () => { }); it('warns when the config file is not a JSON object', () => { - const home = setupHome(); - const configPath = join(home, '.pi', 'grok-cli.json'); + setupHome(); + const configPath = getConfigPath(); mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, JSON.stringify([1, 2, 3])); @@ -79,7 +80,7 @@ describe('grok-cli-vision config', () => { it('warns on invalid JSON', () => { setupHome(); - const configPath = join(process.env.HOME as string, '.pi', 'grok-cli.json'); + const configPath = getConfigPath(); mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, '{ not json'); diff --git a/tests/vision/describe.test.ts b/tests/vision/describe.test.ts index d87fdb8..0639893 100644 --- a/tests/vision/describe.test.ts +++ b/tests/vision/describe.test.ts @@ -3,8 +3,9 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import type { ExtensionContext, ToolResultEvent } from '@earendil-works/pi-coding-agent'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_CONFIG } from '../../src/config.js'; +import { DEFAULT_CONFIG, getConfigPath } from '../../src/config.js'; import { handleReadResult } from '../../src/vision/describe.js'; +import { saveTestAccounts } from './helpers.js'; const BASE_URL = 'https://cli-chat-proxy.grok.com/v1'; const PNG = Buffer.from('fake-png-bytes').toString('base64'); @@ -109,8 +110,8 @@ describe('handleReadResult — no-op cases', () => { }); it('does nothing when routing is disabled via config', async () => { - const configPath = join(process.env.HOME as string, '.pi', 'grok-cli.json'); - mkdirSync(join(process.env.HOME as string, '.pi'), { recursive: true }); + const configPath = getConfigPath(); + mkdirSync(dirname(configPath), { recursive: true }); writeFileSync( configPath, JSON.stringify({ @@ -170,6 +171,22 @@ describe('handleReadResult — image routing', () => { expect(headers.Authorization).toBe('Bearer env-token'); }); + it('uses the last selected Grok alias while a non-Grok model is active', async () => { + saveTestAccounts(); + const getApiKeyForProvider = vi.fn(async () => 'work-token'); + const ctx = { + ...buildCtx(), + model: { provider: 'openai', input: ['text'] }, + modelRegistry: { getApiKeyForProvider }, + } as unknown as ExtensionContext; + + await handleReadResult(readEvent([imageBlock()]), ctx); + + expect(getApiKeyForProvider).toHaveBeenCalledWith('grok-cli-2'); + const headers = (fetchMock.mock.calls[0]?.[1] as RequestInit).headers as Record; + expect(headers.Authorization).toBe('Bearer work-token'); + }); + it('describes multiple images with preserved order and labels', async () => { const result = await handleReadResult( readEvent([ @@ -206,7 +223,7 @@ describe('handleReadResult — image routing', () => { expect.stringMatching(/Describing image/), 'info', ); - const cachePath = join(process.env.HOME as string, '.pi', 'grok-cli-vision-cache.json'); + const cachePath = join(process.env.HOME as string, '.pi', 'grok-cli', 'vision-cache.json'); expect(existsSync(cachePath)).toBe(true); const cache = JSON.parse(readFileSync(cachePath, 'utf-8')); expect(Object.keys(cache.entries)).toHaveLength(1); @@ -385,7 +402,7 @@ describe('handleReadResult — response shapes and resilience', () => { }); it('caps described images at maxImages and notes the skipped remainder', async () => { - const configPath = join(process.env.HOME as string, '.pi', 'grok-cli.json'); + const configPath = getConfigPath(); mkdirSync(dirname(configPath), { recursive: true }); writeFileSync( configPath, diff --git a/tests/vision/helpers.ts b/tests/vision/helpers.ts index a4fb8f6..c7ae076 100644 --- a/tests/vision/helpers.ts +++ b/tests/vision/helpers.ts @@ -2,6 +2,26 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach } from 'vitest'; +import { DEFAULT_CONFIG, saveConfig } from '../../src/config.js'; + +export const TEST_ACCOUNTS = [ + { provider: 'grok-cli', label: 'Personal' }, + { provider: 'grok-cli-2', label: 'Work' }, +]; + +export const oauthCredential = (access: string) => ({ + type: 'oauth' as const, + access, + refresh: `${access}-refresh`, + expires: Date.now() + 60_000, +}); + +export function saveTestAccounts(selectedProvider = 'grok-cli-2') { + saveConfig({ + ...DEFAULT_CONFIG, + accounts: { nextAccountNumber: 3, selectedProvider, items: TEST_ACCOUNTS }, + }); +} /** * Point HOME at a fresh temp dir for the whole test file, restoring it on diff --git a/tests/vision/register.test.ts b/tests/vision/register.test.ts index ea732f0..fe22d3f 100644 --- a/tests/vision/register.test.ts +++ b/tests/vision/register.test.ts @@ -27,15 +27,14 @@ async function setupExtension() { } describe('registerVisionFeature', () => { - it('registers the tool_result handler and four commands', async () => { + it('registers the tool_result handler and three commands', async () => { setupHome(); const { commands, toolResultHandlers } = await setupExtension(); expect(toolResultHandlers).toHaveLength(1); expect([...commands.keys()].sort()).toEqual([ + 'grok-cli-vision', 'grok-cli-vision:cache-clear', - 'grok-cli-vision:off', - 'grok-cli-vision:on', 'grok-cli-vision:status', ]); }); @@ -55,18 +54,21 @@ describe('registerVisionFeature', () => { expect(text).toMatch(/cache: ON \(0 entries/); }); - it('on/off persist enabled state to the config file', async () => { + it('toggles enabled state and persists it to the config file', async () => { setupHome(); const { commands } = await setupExtension(); + const notify = vi.fn(); saveConfig({ ...DEFAULT_CONFIG, imagine: { enabled: false } }); - await commands.get('grok-cli-vision:off')?.handler([], { ui: { notify: vi.fn() } }); + await commands.get('grok-cli-vision')?.handler([], { ui: { notify } }); expect(loadConfig().config.vision.enabled).toBe(false); expect(loadConfig().config.imagine.enabled).toBe(false); + expect(notify).toHaveBeenCalledWith('grok-cli-vision: OFF', 'info'); - await commands.get('grok-cli-vision:on')?.handler([], { ui: { notify: vi.fn() } }); + await commands.get('grok-cli-vision')?.handler([], { ui: { notify } }); expect(loadConfig().config.vision.enabled).toBe(true); expect(loadConfig().config.imagine.enabled).toBe(false); + expect(notify).toHaveBeenCalledWith('grok-cli-vision: ON (grok-build)', 'info'); }); it('clears cached vision descriptions and notifies the user', async () => {