diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c777c59 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|compact", + "hooks": [ + { + "type": "command", + "command": "jq -r '\"SESSION_ID=\" + .session_id'" + } + ] + } + ] + } +} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a8a50b..53094e9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -112,7 +112,9 @@ jobs: # Coverage runs on one OS only. The gate counts lines executed in a # single run, and each platform necessarily leaves the other's branches - # untouched, so requiring 90% on every leg would fail for the wrong reason. + # untouched, so requiring 97% on every leg would fail for the wrong reason. + # What that leaves unmeasured on this leg is `docs/testing.md` → *The + # ceiling*, which also says why the gate sits below the number a run reaches. - name: Run tests with coverage if: matrix.os == 'windows-latest' shell: pwsh diff --git a/.gitignore b/.gitignore index 89a2fd8..0251533 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,13 @@ node_modules/ tests/TestResults/ *.sublime-workspace + +# Local Claude Code settings; the shared SessionStart hook is tracked. +.claude/settings.local.json + +# Agent git worktrees. See AGENTS.md "Multi-Agent Working Tree Discipline". .claude/worktrees/ -.tmp/ \ No newline at end of file + +# Local scratch directory for all agent-generated artifacts (screenshots, +# diffs, trace outputs, experimental scripts). See AGENTS.md "Scratch files". +.tmp/ diff --git a/.opencode/plugins/session-id-injector.js b/.opencode/plugins/session-id-injector.js new file mode 100644 index 0000000..c7e884d --- /dev/null +++ b/.opencode/plugins/session-id-injector.js @@ -0,0 +1,24 @@ +// Puts SESSION_ID into the environment of every shell command, which the +// agent uses as in .tmp/sessions// per AGENTS.md +// "Multi-Agent Working Tree Discipline" rule 3. +// +// It goes in the environment rather than in the system prompt because the +// system prompt is the one part of the request every turn and every session +// shares, and a prompt cache keys on exactly that. A per-session value inside +// it leaves no two sessions a reusable prefix, so the whole system block +// (instructions, AGENTS.md, tool definitions) is reprocessed at every session +// start. Turn-to-turn reuse within a session was never affected, which is why +// the cost hid: it falls entirely on session starts. +// +// Claude Code keeps the literal value in context instead, from the +// SessionStart hook in .claude/settings.json. That is not an oversight to be +// tidied away: its stdout joins the conversation ahead of the first prompt +// rather than the system block, so it sits outside the cached prefix, and its +// static "env" setting cannot carry a per-session value the way this hook can. + +export const SessionIdInjector = async () => ({ + 'shell.env': async (input, output) => { + if (!input.sessionID) return; + output.env.SESSION_ID = input.sessionID; + }, +}); diff --git a/AGENTS.md b/AGENTS.md index 458ad4d..10d1e11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -This file is the canonical agent-instructions source for this repository, read natively by OpenCode and loaded by Claude Code through the `CLAUDE.md` import shim. Single-file PowerShell tool: core logic lives in `switch_claude_account.ps1`; tests live in `tests/` and use Pester 5. It carries the always-on rules as one invariant per area; the contracts behind them are the documents under `docs/`, read on demand through *Reference* at the end. +This file is the canonical agent-instructions source for this repository, read natively by both OpenCode and Claude Code (2.1.277+). Single-file PowerShell tool: core logic lives in `switch_claude_account.ps1`; tests live in `tests/` and use Pester 5. It carries the always-on rules as one invariant per area; the contracts behind them are the documents under `docs/`, read on demand through *Reference* at the end. ## Security Rules @@ -40,23 +40,24 @@ The `usage` action and the identity-fallback path depend on constants extracted ## Platform gotchas -- **Hot-swapping a live client is supported.** `switch` and `monitor` run with Claude Code open; `save`, `warmup` and `monitor -KeepWarm` refuse. `Test-ClaudeRunning` owns the evidence and the exceptions. +- **Hot-swapping a live client is supported.** Every action but `save` runs with Claude Code open, the warm round-robin included; `save` alone refuses. `Test-ClaudeRunning` owns the evidence and that one exception. - **POSIX has no mandatory locking**, so a share-mode test is `-Skip:(-not $IsWindows)` and pairs with a Unix test asserting the inode property instead. - **`Get-SafeName` is Windows-strict on every platform**, and every credential-file operation also passes `-LiteralPath` as defense in depth. - **Guard every `System.Console` call.** `[Console]::CursorVisible` is Windows-only to read and throws off an attached console to write; a failed capture stays `$null` so the restore is skipped rather than defaulted to a wrong value. +- **`Write-Color` takes a role, never a color**, one of `Heading` / `Warning` / `Success` / `Danger` / `Muted` / `Neutral`; `$env:SCA_THEME` picks the palette they render through. A theme's background is alt-screen chrome, never a seventh role, and truecolor is never probed for. - The reasoning for each of these, and token expiry, are `docs/architecture.md` → *Platform behavior*. ## Testing ```powershell -pwsh -NoProfile -File tests/Invoke-Tests.ps1 +pwsh -NoProfile -File tests/Invoke-Tests.ps1; "EXIT=$LASTEXITCODE" ``` -Coverage on `switch_claude_account.ps1` runs by default behind a **90% gate**; `-SkipCoverage` for the fastest local loop. One file per action at `tests/Invoke-Action.Tests.ps1`, every outer `Describe` named `'switch_claude_account'`, and `tests/Common.ps1` dot-sourced from each `BeforeEach` to sandbox both home variables, `CLAUDE_CONFIG_DIR` and `$PROFILE.CurrentUserAllHosts` into `$TestDrive`. The filter recipes, the direct-call pattern, the output-capture rule and the complexity diagnostic are `docs/testing.md`. +The exit code is the verdict, so never narrow the run to find one: a filter that fits the output to a terminal drops the summary and costs a second full run. Coverage on `switch_claude_account.ps1` runs by default behind a **97% gate**, measured on one OS, so 100% is unreachable by construction and the residue is `docs/testing.md` → *The ceiling*; `-SkipCoverage` for the fastest local loop. One file per action at `tests/Invoke-Action.Tests.ps1`, every outer `Describe` named `'switch_claude_account'`, and `tests/Common.ps1` dot-sourced from each `BeforeEach` to sandbox both home variables, `CLAUDE_CONFIG_DIR` and `$PROFILE.CurrentUserAllHosts` into `$TestDrive`. The filter recipes, the direct-call pattern, the output-capture rule, reading the result and the complexity diagnostic are `docs/testing.md`. ## README image regeneration -`pwsh -NoProfile -File tools/Render-ReadmeImages.ps1` re-renders the four SVGs in `docs/images/` via `charmbracelet/freeze`. Re-run when a README example number changes, or when a `Write-Color` / `Get-StatusColor` / `Get-AggregateBarColor` mapping changes. That script's header owns the palette, the truecolor rationale and the README `width` contract. +`pwsh -NoProfile -File tools/Render-ReadmeImages.ps1` re-renders every SVG in `docs/images/` via `charmbracelet/freeze`: four README scenes plus one `theme-.svg` per selectable theme, which is every entry in `$Script:Base16Schemes`, read by dot-sourcing the script, and `default` besides. Re-run when a README example number changes, when a `Write-Color` / `Get-StatusColor` / `Get-AggregateBarColor` mapping changes, or when a theme is added; a new theme's image appears on its own, but its heading and alt text in `docs/themes.md` are hand-maintained. A theme panel takes its canvas from freeze's `--background`, not an SGR behind each row, so the color reaches the window padding too. Every image embeds its font and must: freeze emits no per-glyph positions, so a substituted face moves the text off the geometry and the usage bars stop filling their cells. That script's header owns the palette, the font and truecolor rationale, and the README `width` contract. ## Default Change Workflow @@ -68,7 +69,7 @@ Comments explain **why**, not **what**. Default to no comment; prefer a clearer ## Scratch files -Ad-hoc agent artifacts (screenshots, diffs, scratch scripts, traces) go under `.tmp/sessions//`. `.tmp/` is gitignored. Never write scratch files to `.claude/`, the repo root, or `tests/`. +Every ad-hoc artifact of an agent session (screenshots, diffs, scratch scripts, traces: anything not meant to be committed) goes under `.tmp/sessions//` at the repo root, `` per rule 3 in *Multi-Agent Working Tree Discipline*; `.tmp/` is gitignored. Nowhere else: not `.claude/`, not the repo root, not `tests/` or `tools/`, and not the operating-system temp directory under any name or helper (`$env:TEMP`, `os.tmpdir()`), which sits outside the workspace. ## Multi-Agent Working Tree Discipline @@ -76,7 +77,7 @@ Multiple agents may share this directory; foreign uncommitted changes and untrac 1. **Foreign changes off-limits.** Never run `git checkout --`, `restore --`, `reset --hard`, `clean`, `rm`, `mv`, or `git stash pop/apply` on a path another agent modified or an untracked file another agent created. "Commit and push" does NOT authorize destructive cleanup of foreign paths. 2. **Preflight.** `git status --porcelain -u` at task start and again before `git commit`. -3. **Session-scoped scratch.** Use `` from your runtime's session metadata if exposed; otherwise mint `YYYYMMDD-HHMMSS-`. +3. **Session-scoped scratch.** At task start take `SESSION_ID` from your session-start context (Claude Code) or the shell environment (OpenCode, where it is spent unread in a command and read once with `Write-Output $env:SESSION_ID` for a Write or Edit path; `.opencode/plugins/session-id-injector.js` has why it is not in the prompt), use it as `` and write every scratch artifact into `.tmp/sessions//` under a readable name (`foreign-baseline.diff`). A resumed session gets the same id; unset, it collapses the path to `.tmp/sessions/`, so without one mint `YYYYMMDD-HHMMSS-` and lose resume support. 4. **Stashes session-scoped.** Only with explicit pathspec and tagged message: `git stash push --message "session-: " -- `. Bare `git stash`, `-u`, `--all`, and pop/apply of foreign stashes are forbidden. 5. **Edit and shell writes are mutually exclusive per file.** If a file was written outside the Edit tool, the cached content is stale. Re-Read before the next Edit. If Edit fails with "oldString not found", assume concurrent foreign write: surface to the user, do not guess. 6. **Worktrees.** `.claude/worktrees//` is gitignored. Cleanup with `git worktree remove `; no `--force`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 16538d0..988bb85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ This changelog follows [Common Changelog](https://common-changelog.org) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.2.0] - 2026-09-21 + +_Upgrading is replacing one file. `sca warmup` and `sca monitor -KeepWarm` now run beside an open Claude Code instead of refusing, the `Session` aggregate bar reports a different number wherever a slot has capped its week, and colors are unchanged unless you set the new `SCA_THEME`._ + +### Changed +- Run the warm round-robin of `sca warmup` and `sca monitor -KeepWarm` beside a live Claude Code. +- Average the `Session` aggregate bar over reachable slots only, dropping any slot whose week has capped. +- Pause `sca warmup` for five seconds before the first billable activation when Claude Code is running. + +### Added +- Add `SCA_THEME`, which pins output to an exact palette instead of the terminal's own ANSI colors. +- Add ten themes: the base16 schemes `dracula`, `everforest`, `flexoki`, `gruvbox`, `kanagawa`, `material`, `monokai`, `nord` and `onedark`, plus an original `claude`. +- Paint the watch's alternate screen in the active theme's background, erases and window padding included. +- Add `docs/themes.md`, showing every theme as a full `sca monitor` view under a heading of its own. +- List the available theme names in `sca help` under a new `ENVIRONMENT` section. + +### Fixed +- Mirror the active credentials after every activation, including one whose `claude -p` then threw. +- Stop the warm pass, and skip its restore, when nothing could capture the credentials left active. +- Show the warm pass's restore failure instead of discarding it into a suppressed stream. +- Carry the live-client warning into `sca monitor -KeepWarm`, at startup and at every re-warm. +- Guard the watch's console cursor restore so a failure there cannot unwind the terminal restore. +- Correct the README's claim that a full `Session` bar means the week has capped every slot. + ## [4.1.0] - 2026-09-19 _Upgrading is replacing one file. A hot swap is only followed without a restart by Claude Code >= 2.1.274 or opencode-claude-auth >= 1.5.4, and `sca switch` can now refuse, and exit non-zero, where it previously always succeeded._ @@ -295,6 +319,7 @@ _Upgrading migrates active-slot tracking from hardlinks to a state file on first - Add a README with installation, usage, workflow, Windows notes and testing sections. - Add `CLAUDE.md` with agent guidance for the repo structure, gotchas and script-shape conventions. +[4.2.0]: https://github.com/countzero/switch_claude_account/releases/tag/v4.2.0 [4.1.0]: https://github.com/countzero/switch_claude_account/releases/tag/v4.1.0 [4.0.0]: https://github.com/countzero/switch_claude_account/releases/tag/v4.0.0 [3.0.1]: https://github.com/countzero/switch_claude_account/releases/tag/v3.0.1 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ed5ff9e..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,9 +0,0 @@ -# CLAUDE.md - -The canonical agent instructions for this repository live in `AGENTS.md`. This -file exists only as an import shim so Claude Code loads the same instructions -OpenCode reads natively from `AGENTS.md`. A plain `@import` is used rather than -a symlink because it is the Windows-recommended pattern. Edit `AGENTS.md`, not -this file. - -@AGENTS.md diff --git a/README.md b/README.md index 905c7aa..ed06f4a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A zero-dependency PowerShell utility for Claude Code on Windows, Linux, and macOS that combines secure multi-account management with a live usage dashboard and automated limit-based rotation.

- sca monitor: pool-aggregate Session bar at 22% (green) and Week bar at 62% (yellow), then a five-row slot table with the active 'work' row in green, two inactive 'ok' rows, one yellow 'near limit' row, one red 'limited 7d' row, a right-aligned '▶ switching slot at 95%' header indicator, and a '[Monitor] Rotated from \ + sca monitor: pool-aggregate Session bar at 25% (green) and Week bar at 62% (yellow), then a five-row slot table with the active 'work' row in green, two inactive 'ok' rows, one yellow 'near limit' row, one red 'limited 7d' row, a right-aligned '▶ switching slot at 95%' header indicator, and a '[Monitor] Rotated from \

## Features @@ -143,7 +143,7 @@ The output shows the 5-hour session limit (`Session` column, "Current session" i Decoding the output: -- **Pool-aggregate bars**: sum utilization over `N × 100%` across every slot with numbers to show, whether read live or served from the cache after a failed read, so the bars never contradict the rows beneath them. Bar color: green <50%, yellow ≥50%, red ≥90%. +- **Pool-aggregate bars**: sum utilization over `N × 100%` across every slot with numbers to show, whether read live or served from the cache after a failed read. The `Session` bar reports the capacity you can still reach, so a slot at the 100% `Week` cap leaves it entirely, denominator included: that account serves nothing until its week resets, and its idle `Session` cell describes capacity nobody can spend. The `Week` bar keeps the same slot at its real 100%, because dropping it there would hide the exhaustion. Worth knowing: the `Session` bar therefore improves as slots fall out of the pool, and reads 100% once every slot still in that pool has spent its own session window. A week that has capped every slot reaches the same 100% by a second route: an empty pool is reported as spent rather than left blank. Bar color: green <50%, yellow ≥50%, red ≥90%. - **Active marker (`*`)**: sourced from `~/.claude/.sca-state.json`; appears at the start of the row and inherits the row's color. - **`Account` column**: the OAuth email captured at save time. Shows `—` when the email equals the slot name (deduped filename), the actual email otherwise. - **`Session` / `Week` cells**: `% `. The delta is `(2h 11m)` under 24h with minute precision, `(102h)` at 24h+ with integer hours, or `—` when there is no data. A bucket whose window has already rolled also shows `—`: the percentage it carried describes a window the account has left, so it is dropped rather than shown as stale. @@ -181,10 +181,10 @@ sca usage -Watch -NoColor # strip ANSI color The terminal-tab title is updated on every poll so a backgrounded watch is glanceable from the taskbar / Alt-Tab: - 22% | 62% | Switch Claude Account + 18% | 42% | Switch Claude Account

- sca usage -Watch: pool-aggregate Session bar at 22% (green) and Week bar at 62% (yellow), then a five-row slot table with the active 'work' row in green, two inactive 'ok' rows, one yellow 'near limit' row, one red 'limited 7d' row, and a [Watch] Last poll footer + sca usage -Watch: pool-aggregate Session bar at 25% (green) and Week bar at 62% (yellow), then a five-row slot table with the active 'work' row in green, two inactive 'ok' rows, one yellow 'near limit' row, one red 'limited 7d' row, and a [Watch] Last poll footer

> [!NOTE] @@ -202,7 +202,7 @@ sca monitor -KeepWarm # auto-rotate AND keep every slot warm for `sca monitor -KeepWarm` does more than the one-shot pass: at each poll it re-opens any slot whose 5h window has since closed, so a long session keeps every slot warm instead of letting them all expire ~5h after startup. (A 5h window can only be reopened *after* it closes, so a just-expired slot is re-warmed within one poll, not before.) A per-slot cooldown keeps a slot whose warm keeps failing from being retried every poll. -Both `sca warmup` and `sca monitor -KeepWarm` refuse to operate while Claude Code is running, because both make *every* slot active in turn and a live session would be dragged across all of them ([details](#which-actions-still-need-claude-code-closed)). Both also require the `claude` CLI to be installed and logged in. A slot whose token refresh is temporarily rate-limited is reported and skipped, not retried. `-KeepWarm` is the typical companion to `monitor`: rotation needs every peer slot reporting real data to make good decisions, which keeping them warm guarantees. +Both `sca warmup` and `sca monitor -KeepWarm` run with Claude Code open, and `sca warmup` says so when it finds it: the pass makes *every* slot active in turn, so a live session follows it across each account before landing back where it started, and a prompt sent meanwhile bills whichever slot is mounted ([details](#which-actions-still-need-claude-code-closed)). Both also require the `claude` CLI to be installed and logged in. A slot whose token refresh is temporarily rate-limited is reported and skipped, not retried. `-KeepWarm` is the typical companion to `monitor`: rotation needs every peer slot reporting real data to make good decisions, which keeping them warm guarantees. ### Auto-rotate on usage limit @@ -221,7 +221,7 @@ Peer slots are walked in alphabetical wrap order (same direction as `sca switch`

> [!NOTE] -> **Works with a live client, either one.** Rotation lands in `.credentials.json` and `~/.claude.json`, and both clients follow it without a restart: Claude Code from 2.1.274 on, and OpenCode via [`opencode-claude-auth`](https://github.com/griffinmartin/opencode-claude-auth) **>= 1.5.4**. Leave the app open while `sca monitor` runs. Adding `-KeepWarm` is the exception and still needs Claude Code closed, see [which actions](#which-actions-still-need-claude-code-closed). +> **Works with a live client, either one.** Rotation lands in `.credentials.json` and `~/.claude.json`, and both clients follow it without a restart: Claude Code from 2.1.274 on, and OpenCode via [`opencode-claude-auth`](https://github.com/griffinmartin/opencode-claude-auth) **>= 1.5.4**. Leave the app open while `sca monitor` runs, with or without `-KeepWarm`; see [which actions](#which-actions-still-need-claude-code-closed) for the only one that still needs it closed. ### Install / uninstall alias @@ -279,12 +279,12 @@ Code and run 'sca save work' to capture them by hand. | `switch`, `usage`, `list`, `remove` | fine | `switch` writes one destination and Claude Code follows it | | `monitor` | fine | rotation is one destination at a time, same as `switch` | | `save` | **refuses** | it pairs tokens from `.credentials.json` with an identity from `~/.claude.json`, and a `/login` updates those two separately. Catching that window writes a sidecar naming the wrong account, and nothing later corrects it | -| `warmup`, `monitor -KeepWarm` | **refuses** | both make *every* slot active in turn, so a live session would be dragged across every account and bill whichever one was mounted when you hit enter | +| `warmup`, `monitor -KeepWarm` | fine, with a warning | both make *every* slot active in turn and a live session follows, so a prompt sent mid-pass bills whichever slot is mounted. No login is at risk: Claude Code serializes token refreshes across its own processes and adopts a peer's result rather than racing it, so the `claude -p` a warm pass spawns cannot rotate the token out from under your session | Slot-file updates done by `sca usage`'s token refresh use `MoveFileEx` with retry, so those survive an open Claude Code on `.credentials.json` itself. > [!IMPORTANT] -> **The guard does not catch every install shape.** Claude Code installed from npm (`@anthropic-ai/claude-code`) runs as a `node` process rather than one named `claude`, so `sca` has to recognize it from the process command line instead. That works on Linux. It does **not** work on Windows, where reading command lines costs ~53 s and a guard on every write cannot spend that, nor on macOS, where PowerShell does not expose process command lines at all. On those two platforms, close Claude Code yourself before `sca save` and `sca warmup` rather than relying on the refusal. Claude Code from the native installer is detected on all three. +> **The guard does not catch every install shape.** Claude Code installed from npm (`@anthropic-ai/claude-code`) runs as a `node` process rather than one named `claude`, so `sca` has to recognize it from the process command line instead. That works on Linux. It does **not** work on Windows, where reading command lines costs ~53 s and a guard on every write cannot spend that, nor on macOS, where PowerShell does not expose process command lines at all. On those two platforms, close Claude Code yourself before `sca save` rather than relying on the refusal. Claude Code from the native installer is detected on all three. ## Platform Notes @@ -318,6 +318,29 @@ These rules are Windows-strict on every platform by design, so a slot name yield - `foo.` → `foo` - `CON` → error (reserved device name) +### Theming +By default `sca` colors its output with the standard ANSI colors, which means your terminal decides what they actually look like: the output already matches whatever color scheme you have set, on a light background as well as a dark one. + +If you would rather pin an exact palette, set `SCA_THEME` to one of `claude`, `dracula`, `everforest`, `flexoki`, `gruvbox`, `kanagawa`, `material`, `monokai`, `nord` or `onedark`: + +```powershell +$env:SCA_THEME = 'material' # PowerShell; add to $PROFILE to make it stick +``` + +```bash +export SCA_THEME=material # bash / zsh +``` + +**[docs/themes.md](docs/themes.md) shows every theme**, each rendered as the whole `sca monitor` view so what you see is what you get, with its own heading to link to: [claude](docs/themes.md#claude), [dracula](docs/themes.md#dracula), [everforest](docs/themes.md#everforest), [flexoki](docs/themes.md#flexoki), [gruvbox](docs/themes.md#gruvbox), [kanagawa](docs/themes.md#kanagawa), [material](docs/themes.md#material), [monokai](docs/themes.md#monokai), [nord](docs/themes.md#nord), [onedark](docs/themes.md#onedark). + +Nine are the [base16](https://github.com/tinted-theming/schemes) scheme of the same name, so a palette you know from your editor reads the same here; `claude` is an original one keyed to the interface this tool manages logins for. + +To turn color off entirely, use `-NoColor` or the standard [`NO_COLOR`](https://no-color.org) variable. Both outrank `SCA_THEME`, since a theme says *which* colors to use, not *whether* to use any: + +```bash +export NO_COLOR=1 +``` + ### Profile encoding `sca install` and `sca uninstall` preserve your PowerShell profile's existing encoding (UTF-8 with or without BOM, UTF-16 LE/BE). ANSI-encoded profiles are treated as UTF-8 no-BOM (indistinguishable without a BOM). diff --git a/docs/architecture.md b/docs/architecture.md index 810b426..5a3c11a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,9 +107,9 @@ User-facing form: `README.md` → *File permissions (Linux and macOS)*. ### Hot-swapping a live client Claude Code >= 2.1.274 polls `~/.claude.json` at 1 s and re-`stat`s -`.credentials.json` on every refresh check, so `switch` and `monitor` run with it -open. `save`, `warmup` and `monitor -KeepWarm` still refuse. `Test-ClaudeRunning` owns -the evidence and the exceptions. +`.credentials.json` on every refresh check, so every action but `save` runs with it +open, `warmup` and `monitor -KeepWarm` included. `save` alone still refuses. +`Test-ClaudeRunning` owns the evidence and that one exception. ### POSIX has no mandatory locking @@ -142,6 +142,92 @@ Verification is by execution, not inspection. `Enter-WatchTerminal` and CI legs: the conditions that break them are the conditions the tests run in. A static assertion that a guard is present would have passed against code that never executed. +### Color roles and theming + +Callers of `Write-Color` name a semantic **role**, never a color: `Heading`, `Warning`, +`Success`, `Danger`, `Muted`, `Neutral`. What each means is the palette convention on +`Write-Color` itself; an unknown role and an explicit `$null` both render uncolored, +which is how `Invoke-ListAction` marks an inactive row. That indirection is the whole +point: a palette swap touches no call site. + +`$Script:ThemePalettes` maps role to SGR per theme and `$env:SCA_THEME` picks one, +resolved once per run by `Resolve-ThemePalette` in `Invoke-Main`. Every theme but +`default` is generated by `New-ThemePalette` from a row of `$Script:Base16Schemes`, so +a theme is data and the role-to-slot rule is stated exactly once. Precedence is +`-NoColor` > `$env:NO_COLOR` > `$env:SCA_THEME` > default. `NO_COLOR` outranks a theme +rather than conflicting with it, because naming a theme says *which* colors, not +*whether*; `PlainText` strips truecolor `ESC[38;2;R;G;Bm` by the same regex that strips +a named `ESC[33m`, so no-color mode needs no theme-specific handling. An unknown name +falls back quietly with a `Write-Verbose`: a typo lives in a shell profile, so warning +would print on every invocation for as long as it sits there. + +Three constraints are deliberate and should not be "fixed": + +- **The default palette is palette-relative.** It spells roles as `$PSStyle`'s named + foregrounds, which emit ANSI 30-37/90-97 and let the terminal decide what they look + like. The tool therefore already follows the user's own terminal theme, and stays + legible on any background. A named theme burns in truecolor and overrides that, which + is why one is never selected automatically. +- **Background is chrome, not a role.** A theme may declare `Background` + `Foreground`, + and `Get-WatchChrome` applies them only inside the alternate screen. Everywhere else + output is line-oriented into the user's scrollback, where a background would leave + ragged colored bars in their history for good. +- **No truecolor capability detection.** `COLORTERM` and `TERM` are both unset in a + Windows truecolor terminal, so a probe would answer wrong on the primary platform. + Setting `SCA_THEME` is the user's own assertion that their terminal can render it. + +### The base16 mapping + +`New-ThemePalette` builds every named theme from seven slots by one rule: `Heading` +`base0D`, `Warning` `base0A`, `Success` `base0B`, `Danger` `base08`, `Muted` `base03`, +`Background` `base00`, `Foreground` `base05`. `Muted` takes `base03` ("Comments") and +not `base04` ("status bars") despite a status table being what it renders, because +`base04` sits close enough to `base05` to stop reading as de-emphasized. + +base16 slots carry **syntax-highlighting** meaning, which usually but not always +coincides with the ANSI meaning a status table needs. Where it does not, the scheme is +unusable regardless of how it looks: github's port puts orange in `base08` and pale +blue in `base0B`, so `Danger` would render orange and `Success` blue and a glance at +the table would misread which slots are healthy. github is therefore absent despite +having an upstream, and the suite hue-checks both slots on every scheme so a theme +added later cannot reintroduce it. + +### Alt-screen chrome + +`Background` and `Foreground` travel together: painting a canvas without pinning a +foreground leaves a light-terminal user reading dark default text on a dark background. +Inside the frame the pair becomes the effective default, which is the second reason +`Neutral` stays out of the palette — it inherits the chrome foreground there and the +terminal's foreground in scrollback, and both are right. + +`ConvertTo-WatchFrameSequence` weaves chrome in at three points, because a background is +screen state rather than a property of a string: once after `ESC[H`; re-asserted after +every `ESC[0m`, since `Write-Color` ends each run with a full reset that clears +background along with foreground; and before each `ESC[K` and the trailing `ESC[0J` so +the erases fill with it. Consecutive identical runs are collapsed, a repeated SGR being +a no-op, so a 1 Hz repaint carries no redundant bytes. `Enter-WatchTerminal` fills once +on entry to avoid a flash of the terminal background before the first frame; that fill +uses `ESC[0J`, never `ESC[2J`, which the watch-family guard forbids. + +Two caveats are deliberate. Erases filling with the current background is +`back_color_erase`, implemented by Windows Terminal, conhost, iTerm2, kitty, Alacritty, +VTE and WezTerm but not universal; where it is missing the written cells still carry the +background and only the erased tail does not, so the frame degrades to a ragged right +edge rather than breaking. And the `PlainText` check in `Get-WatchChrome` cannot be +dropped as redundant: chrome reaches the terminal through `Write-VTSequence` → +`[Console]::Out.Write`, which bypasses the `StringDecorated` filter that gives every +`Write-Color` path no-color mode for free. + +`Neutral` is absent from every truecolor theme on purpose. It marks a steady-state row +carrying no verdict, so it has to stay readable on a light *and* a dark background; any +fixed hex loses one of the two, and falling through to uncolored is correct on both. + +`tools/Render-ReadmeImages.ps1` hardcodes the Campbell hexes that Windows Terminal +renders the **default** theme as. It is not a theme entry and the README images are +rendered with `SCA_THEME` unset. + +User-facing form: `README.md` → *Theming*. + ### Token expiry OAuth tokens refresh after roughly an hour of inactivity. Without a daemon a slot file diff --git a/docs/claude-code-internals.md b/docs/claude-code-internals.md index dd40c4b..3babcde 100644 --- a/docs/claude-code-internals.md +++ b/docs/claude-code-internals.md @@ -117,6 +117,55 @@ The binary also lowercases uuids on some of its own comparison paths, so two records of one account can differ in case alone. Compare them case-insensitively. +### Token refresh is request-driven and cross-process locked + +Extracted from `claude.exe` **2.1.278** on 2026-09-20. This is the evidence +behind the rule at `Test-ClaudeRunning` that a warm pass may spawn `claude -p` +beside a live client, and behind the caveat at `Update-SlotTokens` that sca's +own refresh is still a race. + +**No timer.** The refresh entry point is `Wxe({retryCount, force, +entryAccessToken, credentials, storageV5, usesLoginOffFirstParty})`, wrapped as +a boolean by `Ws(e)`. All of its call sites are request paths: 401 recovery, +the bearer-attribution preflight (`Qvt`), the request-header build, and a poll +authentication check. None of the binary's `setInterval` call sites reaches it; +those drive remote-payload refresh, certificate rotation, MCP progress +notifications, and a 30 s keychain re-check that only runs when **no** token +was found. An idle client therefore never refreshes on its own, and the window +in which it can collide with anything is the window in which it is serving a +request. + +**Cross-process lock, with peer-adopt rather than a race.** The core is `eE`, +and it guards the grant three times over: + +| Step | Behavior | +| --------------------------------------- | --------------------------------------------------------------------------------------------- | +| Pre-check | returns `not_needed` unless the access token is at or near expiry | +| Re-read before locking | if `accessToken` changed since entry, returns `refreshed` and uses the peer's token | +| Lock acquire | `ELOCKED` retries 5 times at 1000 + random(1000) ms, then gives up as `lock_busy` / `lock_timeout` | +| Re-read under the lock | same `accessToken` comparison again before the request goes out | +| On refresh failure | re-reads once more; a moved token still returns `refreshed` | + +So two Claude Code processes on one account cannot both rotate the refresh +token: the loser adopts the winner's result. Anthropic instruments the path for +exactly this, with `tengu_oauth_token_refresh_race_resolved` and +`tengu_oauth_token_refresh_race_recovered`. + +sca is **not** a participant. `Update-SlotTokens` posts to the token endpoint +without taking that lock, so an sca refresh can still rotate underneath a live +client; only claude-versus-claude is serialized. + +Re-verify with the recipe above, then: + +```powershell +$text | Select-String 'tengu_oauth_token_refresh_lock_acquiring' # the lock exists +$text | Select-String 'tengu_oauth_token_refresh_race_resolved' # peer-adopt exists +$text | Select-String 'grant_type:"refresh_token"' # the refresh primitive +``` + +Staleness shows up as a missing marker: lose the lock markers and the warm pass +needs its refusal back. + ## Credential storage Extracted from `claude.exe` 2.1.274 with the recipe above. diff --git a/docs/images/monitor.svg b/docs/images/monitor.svg index 6370ef3..a6f9f62 100644 --- a/docs/images/monitor.svg +++ b/docs/images/monitor.svg @@ -10,6 +10,6 @@ } -[Usage] Plan usage ▶ switching slot at 95% Session [█████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 22% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 diff --git a/docs/images/theme-claude.svg b/docs/images/theme-claude.svg new file mode 100644 index 0000000..5639e13 --- /dev/null +++ b/docs/images/theme-claude.svg @@ -0,0 +1,15 @@ + + + + + +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 + + diff --git a/docs/images/theme-default.svg b/docs/images/theme-default.svg new file mode 100644 index 0000000..a6f9f62 --- /dev/null +++ b/docs/images/theme-default.svg @@ -0,0 +1,15 @@ + + + + + +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 + + diff --git a/docs/images/theme-dracula.svg b/docs/images/theme-dracula.svg new file mode 100644 index 0000000..9d58489 --- /dev/null +++ b/docs/images/theme-dracula.svg @@ -0,0 +1,15 @@ + + + + + +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 + + diff --git a/docs/images/theme-everforest.svg b/docs/images/theme-everforest.svg new file mode 100644 index 0000000..ba9178d --- /dev/null +++ b/docs/images/theme-everforest.svg @@ -0,0 +1,15 @@ + + + + + +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 + + diff --git a/docs/images/theme-flexoki.svg b/docs/images/theme-flexoki.svg new file mode 100644 index 0000000..12e9116 --- /dev/null +++ b/docs/images/theme-flexoki.svg @@ -0,0 +1,15 @@ + + + + + +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 + + diff --git a/docs/images/theme-gruvbox.svg b/docs/images/theme-gruvbox.svg new file mode 100644 index 0000000..b503c27 --- /dev/null +++ b/docs/images/theme-gruvbox.svg @@ -0,0 +1,15 @@ + + + + + +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 + + diff --git a/docs/images/theme-kanagawa.svg b/docs/images/theme-kanagawa.svg new file mode 100644 index 0000000..c49e469 --- /dev/null +++ b/docs/images/theme-kanagawa.svg @@ -0,0 +1,15 @@ + + + + + +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 + + diff --git a/docs/images/theme-material.svg b/docs/images/theme-material.svg new file mode 100644 index 0000000..9972bf5 --- /dev/null +++ b/docs/images/theme-material.svg @@ -0,0 +1,15 @@ + + + + + +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 + + diff --git a/docs/images/theme-monokai.svg b/docs/images/theme-monokai.svg new file mode 100644 index 0000000..9e311de --- /dev/null +++ b/docs/images/theme-monokai.svg @@ -0,0 +1,15 @@ + + + + + +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 + + diff --git a/docs/images/theme-nord.svg b/docs/images/theme-nord.svg new file mode 100644 index 0000000..4d171a2 --- /dev/null +++ b/docs/images/theme-nord.svg @@ -0,0 +1,15 @@ + + + + + +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 + + diff --git a/docs/images/theme-onedark.svg b/docs/images/theme-onedark.svg new file mode 100644 index 0000000..f44a189 --- /dev/null +++ b/docs/images/theme-onedark.svg @@ -0,0 +1,15 @@ + + + + + +[Usage] Plan usage ▶ switching slot at 95% Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Monitor] Rotated from "legacy" to "work" at 14:31:58[Watch] Last poll at 14:32:07 + + diff --git a/docs/images/usage-watch.svg b/docs/images/usage-watch.svg index 5a769b0..eb170ef 100644 --- a/docs/images/usage-watch.svg +++ b/docs/images/usage-watch.svg @@ -10,6 +10,6 @@ } -[Usage] Plan usage Session [█████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 22% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Watch] Last poll: 14:32:07 +[Usage] Plan usage Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25% Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62% Slot Account Session Week Status ----------- --------------------- ------------- ----------- ------ * work alex@acme.io 18% (2h 11m) 42% (102h) ok personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok dev alex@startup.dev 9% (3h 41m) 34% (118h) ok client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d[Watch] Last poll: 14:32:07 diff --git a/docs/testing.md b/docs/testing.md index ef1ef0a..e6bd7a0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -17,16 +17,34 @@ pwsh -NoProfile -Command "Import-Module Pester -MinimumVersion 5.5.0; Invoke-Pes The runner auto-installs Pester 5 (CurrentUser scope) on first use. PSScriptAnalyzer, if installed, runs in advisory mode. Coverage on `switch_claude_account.ps1` runs by -default with a **90% gate** (`-CoverageThreshold ` to override, `0` disables the +default with a **97% gate** (`-CoverageThreshold ` to override, `0` disables the gate but keeps the summary); JaCoCo XML lands in `tests/TestResults/coverage.xml` (gitignored). `-SkipCoverage` for the fastest local loop. +### Reading the result + +```powershell +pwsh -NoProfile -File tests/Invoke-Tests.ps1; "EXIT=$LASTEXITCODE" +``` + +Ask for the exit code in the same command as the run. It is the whole verdict, tests +and coverage gate together, and the runner's header comment owns that contract. + +Never narrow the run to find the verdict instead. `-Output Detailed` prints a line per +test, so the `Tests Passed: N, Failed: N` summary and the coverage line sit under +roughly a thousand of them, and a filter picked to fit a terminal (`Select-Object +-Last`, a `Select-String` pattern) is overwhelmingly likely to cut exactly those two +lines. The only way back to them is a second full run of a suite that takes minutes. +An agent harness that truncates long output has already written the whole of it to a +file and says where: search that file rather than narrowing the command. On a nonzero +exit the failures are the `[-]` lines. + ## Test conventions - **Layout**: one file per action at `tests/Invoke-Action.Tests.ps1`, plus cross-cutting suites (`Helpers`, `Profile-Install`, `Invoke-Reconcile`, - `Invoke-AutoRotation`, `State-File`). Every outer `Describe` is named - `'switch_claude_account'` so `-FullNameFilter` recipes work uniformly. + `Invoke-AutoRotation`, `State-File`, `Test-ClaudeRunning`). Every outer `Describe` + is named `'switch_claude_account'` so `-FullNameFilter` recipes work uniformly. - **Sandboxing**: `tests/Common.ps1`, dot-sourced from each `BeforeEach`, sandboxes `$env:USERPROFILE`, `$env:HOME`, `$env:CLAUDE_CONFIG_DIR` and `$PROFILE.CurrentUserAllHosts` per test via `$TestDrive` (both home variables, @@ -36,11 +54,51 @@ gate but keeps the summary); JaCoCo XML lands in `tests/TestResults/coverage.xml assertions see ANSI-stripped output. - **Direct-call pattern**: the script is dot-sourced and tests call `Invoke-*Action` directly, bypassing `Invoke-Main`. The `-NoColor` `try/finally` in `Invoke-Main` - therefore never fires in tests; `Common.ps1` substitutes for it. + therefore never fires in tests; `Common.ps1` substitutes for it. `Invoke-Main`'s own + dispatch is covered in `Helpers.Tests.ps1` by assigning the script's `Param()` + variables in the `It` body; a `-ForEach` key may not be named `Action`, which + collides with that parameter and expands to empty. +- **Blanket mocks**: `Common.ps1` mocks `Test-ClaudeRunning` for the whole suite so no + action refuses on the developer's own processes. A mock cannot be lifted once set, so + the one file that needs the real body sets `$script:ScaKeepRealClaudeRunning` before + dot-sourcing `Common.ps1` and mocks `Get-Process` instead. Nothing else may. - **Output capture**: `6>&1 | Out-String` captures `Write-Host` (information stream 6). Stream 4 (`Write-Progress`) is not captured by that pattern; relevant when adding rendering helpers. +## The ceiling + +Coverage is collected on one OS (`windows-latest` in CI, per the comment on that +workflow step), so **100% is not reachable and is not the target**. A full Windows run +lands at about **98.6%**, leaving 34 instructions in three groups. Check a new gap +against these before assuming it is a missing test. + +| Group | Instr | What it is | +| ------------------------ | ----- | --------------------------------------------------------------------------- | +| Unix-only code | 25 | The non-Windows arms, all covered on the Linux and macOS legs | +| No seam in the harness | 5 | Failures the test host cannot provoke | +| Deliberately not tested | 4 | Defense-in-depth arms reachable only by mocking an internal | + +**Unix-only**: the `$ScaHomeDir` and `Test-SamePath` platform arms, `UnixCreateMode` +in `Write-PrivateFileBytes`, the `HOME` name in `Assert-CredentialDir`, the 0700 +`New-CredentialDirectory` path, the whole `Repair-CredentialFileModes` body, and +`Test-ClaudeRunning`'s command-line probe. Each has tests; they run on the other legs. + +**No seam**: `Write-PrivateFileBytes`' cleanup needs a write that fails after the +stream opened, which means a real ENOSPC. `Enter-WatchTerminal`'s two `catch` arms +need `$Host.UI.RawUI` or the `OutputEncoding` setter to throw, and `$Host` is a +**Constant** variable, so it cannot be swapped for a stub that does. + +**Not tested on purpose**: the `emailAddress`-less refusal in `Invoke-SaveAction` +(both identity sources already reject a blank address), the two `Format-AggregateBars` +clamps, and the negative-budget floor in `Format-UsageAdvisory`. Reaching any of them +means mocking the function immediately upstream, which pins the mock rather than +anything that can regress. + +Raising the gate above 97 is therefore the wrong reflex: the headroom is what the next +platform-conditional branch spends, and losing it fails the run for a branch that is +tested, just not on this leg. + ## What the suite cannot catch The tests mock `Invoke-RestMethod` by `$Uri` and verify shape contract only, so they diff --git a/docs/themes.md b/docs/themes.md new file mode 100644 index 0000000..a6feb1c --- /dev/null +++ b/docs/themes.md @@ -0,0 +1,97 @@ +# Themes + +Every panel below is the whole `sca monitor` view rendered in that theme, so what you see is what you get. Each has its own heading to link to: [`#claude`](#claude), [`#nord`](#nord), and so on. + +```powershell +$env:SCA_THEME = 'nord' # PowerShell; add to $PROFILE to make it stick +``` + +```bash +export SCA_THEME=nord # bash / zsh; add to ~/.bashrc or ~/.zshrc +``` + +The name is case-insensitive. An unrecognized one falls back to `default` without complaint, since a typo in a shell profile would otherwise print a warning on every command you run; add `-Verbose` to any action to see the miss and the valid names. `sca help` lists them too. + +**`default` is the one panel that cannot be honest.** It carries no colors of its own: it emits the standard ANSI codes and lets your terminal decide what they look like, so it already matches whatever scheme you have configured, on a light background as readily as a dark one. Its panel has to pick one interpretation to draw, and picks Windows Terminal's Campbell. If you like how your terminal already looks, `default` is the right answer and no image can show you that. + +The other ten are absolute 24-bit color and render exactly as pictured. Nine are the [base16](https://github.com/tinted-theming/schemes) scheme of the same name, dark variant, so a palette you know from your editor reads the same here. `claude` has no upstream: it is an original palette keyed to the warm accent and near-black of the Claude Code interface this tool manages logins for. + +--- + +## default + +The sca monitor view in the default theme, drawn in Windows Terminal's Campbell palette + +## claude + +The sca monitor view in the claude theme: warm orange headings, amber near-limit rows, sage green ok rows and a crimson limited row on a near-black background + +## dracula + +The sca monitor view in the dracula theme: purple headings, pale yellow near-limit rows, bright green ok rows and a coral limited row on a dark blue-grey background + +## everforest + +The sca monitor view in the everforest theme: muted teal headings, sand near-limit rows, soft green ok rows and a dusty red limited row on a desaturated green-grey background + +## flexoki + +The sca monitor view in the flexoki theme: steel blue headings, ochre near-limit rows, olive ok rows and a brick limited row on a near-black background + +## gruvbox + +The sca monitor view in the gruvbox theme: desaturated blue headings, warm yellow near-limit rows, olive-green ok rows and a bright red limited row on a warm dark grey background + +## kanagawa + +The sca monitor view in the kanagawa theme: soft indigo headings, muted gold near-limit rows, moss green ok rows and a deep red limited row on a dark violet-grey background + +## material + +The sca monitor view in the material theme: periwinkle blue headings, amber near-limit rows, light green ok rows and a salmon limited row on a blue-grey background + +## monokai + +The sca monitor view in the monokai theme: cyan headings, sand near-limit rows, lime ok rows and a magenta-pink limited row on a warm near-black background + +## nord + +The sca monitor view in the nord theme: slate blue headings, pale gold near-limit rows, sage ok rows and a muted rose limited row on a cool dark blue background + +## onedark + +The sca monitor view in the onedark theme: bright blue headings, tan near-limit rows, green ok rows and a soft red limited row on a dark blue-grey background + +--- + +## How a theme is built + +Seven values per theme, by one fixed rule: `Heading` `base0D`, `Warning` `base0A`, `Success` `base0B`, `Danger` `base08`, `Muted` `base03`, `Background` `base00`, and `base05` for body text. The values themselves live in `$Script:Base16Schemes` in `switch_claude_account.ps1`. + +`Muted` takes `base03` ("Comments") rather than `base04` ("status bars"), even though a status bar is what it renders: `base04` sits close enough to `base05` that the row stops reading as de-emphasized, and being dimmer than the body text is the whole job. + +Two constraints hold across every theme and the test suite enforces both. `Danger` must read as red and `Success` as green, because a status table is scanned rather than read: a theme that put orange where red belongs would make a limited slot look merely busy. That rule is why `github` is absent despite having a perfectly good base16 port, its slots carrying orange and pale blue. `claude` meets it by a deliberate margin, its `Danger` pulled to hue 349 so it cannot blur against an accent at hue 15. + +## What a theme does and does not touch + +**The background only appears in the full-screen views**, `sca usage -Watch` and `sca monitor`. Those own the whole alternate screen and hand it back untouched on exit. Every other command prints into your scrollback, where a background would leave ragged colored bars in your shell history for good, so none is painted there. + +**Layout never changes.** Every column lines up identically whichever theme is active. + +**A theme needs 24-bit color**, which Windows Terminal, iTerm2, kitty, Alacritty, WezTerm and recent GNOME Terminal all have. Setting the variable is taken as your word that yours does; `sca` does not probe, because the usual probe (`COLORTERM`) is unset on Windows even where truecolor works perfectly. + +## Turning color off + +`-NoColor` or the standard [`NO_COLOR`](https://no-color.org) variable. Both outrank `SCA_THEME`, because naming a theme says *which* colors to use, not *whether* to use any. + +```bash +export NO_COLOR=1 +``` + +## Theming everything at once instead + +If you would rather not theme each tool separately, set your terminal's own 16-color palette: Windows Terminal's *Color schemes*, or [`concfg`](https://github.com/lukesampson/concfg) for CMD and the legacy console. `default` uses the standard ANSI colors precisely so it inherits that work, and then `SCA_THEME` is something you never need to set. + +## Regenerating these images + +`pwsh -NoProfile -File tools/Render-ReadmeImages.ps1` re-renders every SVG in `docs/images/`, these included. The panels are generated from the script's own scheme table and share their scene with `monitor.svg`, so adding a theme and re-running produces its image automatically; only the heading and alt text on this page are maintained by hand. diff --git a/switch_claude_account.ps1 b/switch_claude_account.ps1 index 4b7537c..ae6ac2e 100644 --- a/switch_claude_account.ps1 +++ b/switch_claude_account.ps1 @@ -104,10 +104,12 @@ Param ( # -NoColor: suppress ANSI colour for this invocation. Mechanism is on # Write-Color; Invoke-Main flips $PSStyle.OutputRendering to strip the # inline SGR that helper emits. - # Precedence: -NoColor > $env:NO_COLOR non-empty > colored. + # Precedence: -NoColor > $env:NO_COLOR non-empty > $env:SCA_THEME > colored. # NO_COLOR (https://no-color.org) is the de facto standard for opting out # without a per-invocation flag. Watch mode still works in B&W: its # alt-buffer / sync / cursor VT sequences are not SGR and survive. + # There is no -Theme flag to pair with this one: a palette is a standing + # preference that belongs in a shell profile, not a per-invocation choice. [switch] $NoColor, # -Version: print $Script:ScriptVersion and exit before any action runs. @@ -249,7 +251,7 @@ $ProfilePath = $PROFILE.CurrentUserAllHosts # the [switch] $Version parameter declared above: a same-named parameter # enforces its [switch] type on every assignment to the script-scope # variable, silently coercing this string to $true. -$Script:ScriptVersion = '4.1.0' +$Script:ScriptVersion = '4.2.0' # Marker constants delimiting the block we manage in the user's profile. # Kept at script scope so both Add-To-Profile and Remove-From-Profile share @@ -430,14 +432,15 @@ $Script:UtilLimitPct = 100 # aggregates flip to red sooner because one fully-burned slot in a # multi-slot pool barely moves the aggregate): # -# usedPct >= AggregateRedPct -> Red (pool nearly exhausted) -# usedPct >= AggregateYellowPct -> Yellow (half or more burned) -# otherwise -> Green +# usedPct >= AggregateRedPct -> Danger (pool nearly exhausted) +# usedPct >= AggregateYellowPct -> Warning (half or more burned) +# otherwise -> Success # -# Red anchored to UtilWarnPct (90) so 'red' carries the same near-cap -# meaning at per-slot and pool scale; pure 100% would be a knife-edge -# transition that fires only after the pool is already exhausted. -# Yellow at the half-burned mark. +# The constants keep their color names because they are calibrated against +# what the default palette renders. Danger is anchored to UtilWarnPct (90) +# so 'red' carries the same near-cap meaning at per-slot and pool scale; +# pure 100% would be a knife-edge transition that fires only after the pool +# is already exhausted. Warning sits at the half-burned mark. $Script:AggregateRedPct = 90 $Script:AggregateYellowPct = 50 @@ -1062,18 +1065,28 @@ function Update-ScaState { # one account, handed a second account's .credentials.json 4 s in, died 4 s # later on the SECOND account's 5h limit. # -# So swapping accounts under a live Claude Code works, and `sca switch` and -# `sca monitor` run beside one. What refuses, and why: -# -# * save Captures .credentials.json and an identity in the same breath, -# one from each file. Catching the window mislabels the slot -# permanently, and unlike a bad mirror nothing later corrects it. -# * warmup, monitor -KeepWarm -# Both make EVERY slot active in turn. A live session would be -# dragged across every account on the machine and bill whichever -# one was mounted when the user hit enter. Rotation moves to one -# chosen destination and stays; a round-robin underneath a user is -# not something they can reason about. +# So swapping accounts under a live Claude Code works, and every action but one +# runs beside it. `save` alone refuses: it captures .credentials.json and an +# identity in the same breath, one from each file, so catching the window +# mislabels the slot permanently and, unlike a bad mirror, nothing later +# corrects it. +# +# `warmup` and `monitor -KeepWarm` refused too until their round-robin was made +# safe. Both make EVERY slot active in turn, so a live session follows them +# across every account, and the fear was that the `claude -p` a warm pass +# spawns would race the live client for the cold slot's grant and leave one of +# them holding a rotated refresh token. It cannot: Claude Code refreshes only +# when a request needs it, never on a timer, and serializes refreshes across +# processes behind a lock file, adopting a peer's result instead of racing it +# (docs/claude-code-internals.md -> Token refresh). The real loss was sca's +# own: the round-robin discarded a refresh claude had landed whenever the +# activation then failed for some other reason. That is fixed where it +# happened, in Invoke-WarmAllSlots: the mirror runs in a finally so no throw +# can skip it, and the pass stops instead of swapping again whenever that +# mirror cannot vouch for the bytes. What is left is a prompt sent mid-pass +# billing whichever slot is mounted. A surprise, not a loss, which `sca +# warmup` both states and pauses for, and which the watch carries in its +# footer latch for as long as the round-robin keeps running. # # What sca risks by writing beside a live client, in both files: # @@ -1150,11 +1163,11 @@ function Test-ClaudeRunning { # True when any process in $Processes is an npm-installed Claude Code. # -# Split out of Test-ClaudeRunning because that function's own body cannot be -# tested: a Get-Process mock does not reach a dot-sourced function the way an -# Invoke-RestMethod or Get-ChildItem mock does (verified against Pester 5.7). -# Taking the process list as a parameter puts the part worth pinning, the -# pattern itself, under test on every platform. +# Split out of Test-ClaudeRunning so the pattern can be pinned on every +# platform: the caller reaches this probe only on Unix, and a Windows-only +# coverage gate would otherwise never execute the one line here worth being +# wrong about. Taking the process list as a parameter, rather than calling +# Get-Process itself, is what makes that possible. # # The pattern is the npm package's own entry point, # @anthropic-ai/claude-code/cli.js, which argv carries as the resolved script @@ -1236,9 +1249,14 @@ function Get-OAuthAccountFromClaudeJson { # Set-OAuthAccountInClaudeJson to substitute new field values into the # raw JSON text without depending on PowerShell's JSON serializer (which # would re-format the entire 18 KB+ config file and risk drift). +# +# A JSON `null` is not among the outputs. AllowNull lets a caller pass $null, +# but the binder still converts it to '' on the way into a [string] parameter, +# so the only reachable answer for one is '""'. Set-OAuthAccountInClaudeJson +# wants exactly that: it substitutes into a field Claude Code re-reads, and an +# unquoted null there is a different type, not a blanker value. function ConvertTo-ScaJsonString { Param ([AllowEmptyString()] [AllowNull()] [string] $Value) - if ($null -eq $Value) { return 'null' } $escaped = $Value.Replace('\', '\\').Replace('"', '\"') $escaped = $escaped.Replace("`b", '\b').Replace("`f", '\f').Replace("`n", '\n').Replace("`r", '\r').Replace("`t", '\t') return '"' + $escaped + '"' @@ -1562,6 +1580,26 @@ function Show-Help { # letting Join-Path's binder throw mid-render. $unresolved = '(unresolved: set HOME or CLAUDE_CONFIG_DIR)' $slotGlob = if ($CredDir) { Join-Path $CredDir '.credentials.().json' } else { $unresolved } + # Wrapped into the 21-column description gutter rather than joined into + # one line: the list grows with every theme added and had already run to + # 124 columns, well past the width the rest of this screen keeps to. + $themeGutter = ' ' * 21 + $themeLines = @() + $themeLine = '' + foreach ($themeName in ($Script:ThemePalettes.Keys | Sort-Object)) { + $candidate = if ($themeLine) { "$themeLine, $themeName" } else { $themeName } + if ($candidate.Length -gt (80 - $themeGutter.Length)) { + $themeLines += "$themeLine," + $themeLine = $themeName + } else { + $themeLine = $candidate + } + } + if ($themeLine) { $themeLines += $themeLine } + # Joined into ONE element rather than spliced in as several: a nested + # array reaches Write-Host as a single argument and gets space-joined + # back onto one line, undoing the wrap. + $themeBlock = ($themeLines | ForEach-Object { $themeGutter + $_ }) -join "`n" $lines = @( "", @@ -1628,9 +1666,17 @@ function Show-Help { " State : $(if ($StateFile) { $StateFile } else { $unresolved })", " PS profile : $ProfilePath", "", + # Theme names come from the palette table itself so this list cannot + # drift as themes are added. + "ENVIRONMENT", + " SCA_THEME Color theme. One of:", + $themeBlock, + " NO_COLOR Set non-empty to suppress all color (no-color.org)", + " CLAUDE_CONFIG_DIR Override the directory holding the files above", + "", "NOTES", - " • 'switch' and 'monitor' work with Claude Code open; it follows the swap.", - " • Close Claude Code / VS Code before 'save', 'warmup', or 'monitor -KeepWarm'.", + " • 'switch', 'monitor' and 'warmup' work with Claude Code open; it follows the swap.", + " • Close Claude Code / VS Code before 'save'; every other action runs beside it.", " • Needs Claude Code >= 2.1.274, or OpenCode + opencode-claude-auth >= 1.5.4.", "" ) @@ -1638,6 +1684,159 @@ function Show-Help { $lines | ForEach-Object { Write-Host $_ } } +# The base16 slots each role is built from. Stated once, here, so that every +# scheme below stays pure data and no theme can wire a role differently from +# its siblings. +# +# Heading base0D Warning base0A Success base0B +# Danger base08 Muted base03 +# Background base00 Foreground base05 +# +# Muted takes base03 ("Comments, Invisibles"), not base04 ("status bars"), +# even though a status table is literally what it renders. base04 sits close +# enough to base05 that the row stops reading as de-emphasized, and dimmer +# than the body text is the whole job. The resulting base03-on-base00 ratio +# runs 1.7:1 to 3.8:1 across these schemes, which is each theme's own comment +# contrast rather than something to correct here. +# +# base16 slots carry SYNTAX-highlighting meaning, which usually but not +# always coincides with the ANSI meaning a status table needs. Where it does +# not, a scheme is unusable no matter how good it looks: github's port puts +# orange in base08 and pale blue in base0B, so Danger would render orange and +# Success blue and a glance at the table would misread which slots are +# healthy. That is why github is absent despite having an upstream, and why +# the suite hue-checks both slots rather than leaving the rule to review. +function New-ThemePalette { + Param ([Parameter(Mandatory)] [hashtable] $Scheme) + + return @{ + Heading = $PSStyle.Foreground.FromRgb($Scheme.base0D) + Warning = $PSStyle.Foreground.FromRgb($Scheme.base0A) + Success = $PSStyle.Foreground.FromRgb($Scheme.base0B) + Danger = $PSStyle.Foreground.FromRgb($Scheme.base08) + Muted = $PSStyle.Foreground.FromRgb($Scheme.base03) + + # Alt-screen chrome; see Get-WatchChrome for where it applies and + # why it stops at the edge of the watch frame. + Background = $PSStyle.Background.FromRgb($Scheme.base00) + Foreground = $PSStyle.Foreground.FromRgb($Scheme.base05) + } +} + +# The seven slots of each scheme this tool ships. All but `claude` are +# transcribed from the base16 definitions in tinted-theming/schemes (MIT), +# dark variants only: a light scheme is legible but doubles a list that is +# read at a glance. Adding a theme is a row here and nothing else; the +# integrity tests pick it up automatically. +# +# `claude` has no upstream. It is an original palette in the same seven-slot +# shape, keyed to the warm accent and near-black of the Claude Code interface +# this tool manages logins for. Its Danger is pulled to hue 349 rather than a +# true red on purpose: the accent that makes the theme recognizable sits at +# hue 15, and a Danger within ~20 degrees of the heading is the same glance +# ambiguity that disqualified github, so the two are held 26 degrees apart. +# Monokai is the precedent for a rose-leaning Danger reading correctly. +$Script:Base16Schemes = @{ + claude = @{ base00 = 0x1F1E1D; base03 = 0x6C6A66; base05 = 0xF0EEE6; base08 = 0xC9485F; base0A = 0xD9A441; base0B = 0x7D9663; base0D = 0xD97757 } + dracula = @{ base00 = 0x282A36; base03 = 0x6272A4; base05 = 0xF8F8F2; base08 = 0xFF5555; base0A = 0xF1FA8C; base0B = 0x50FA7B; base0D = 0xBD93F9 } + everforest = @{ base00 = 0x2D353B; base03 = 0x859289; base05 = 0xD3C6AA; base08 = 0xE67E80; base0A = 0xDBBC7F; base0B = 0xA7C080; base0D = 0x7FBBB3 } + flexoki = @{ base00 = 0x100F0F; base03 = 0x575653; base05 = 0xCECDC3; base08 = 0xD14D41; base0A = 0xD0A215; base0B = 0x879A39; base0D = 0x4385BE } + gruvbox = @{ base00 = 0x282828; base03 = 0x665C54; base05 = 0xD5C4A1; base08 = 0xFB4934; base0A = 0xFABD2F; base0B = 0xB8BB26; base0D = 0x83A598 } + kanagawa = @{ base00 = 0x1F1F28; base03 = 0x54546D; base05 = 0xDCD7BA; base08 = 0xC34043; base0A = 0xC0A36E; base0B = 0x76946A; base0D = 0x7E9CD8 } + material = @{ base00 = 0x263238; base03 = 0x546E7A; base05 = 0xEEFFFF; base08 = 0xF07178; base0A = 0xFFCB6B; base0B = 0xC3E88D; base0D = 0x82AAFF } + monokai = @{ base00 = 0x272822; base03 = 0x75715E; base05 = 0xF8F8F2; base08 = 0xF92672; base0A = 0xF4BF75; base0B = 0xA6E22E; base0D = 0x66D9EF } + nord = @{ base00 = 0x2E3440; base03 = 0x4C566A; base05 = 0xE5E9F0; base08 = 0xBF616A; base0A = 0xEBCB8B; base0B = 0xA3BE8C; base0D = 0x81A1C1 } + onedark = @{ base00 = 0x282C34; base03 = 0x545862; base05 = 0xABB2BF; base08 = 0xE06C75; base0A = 0xE5C07B; base0B = 0x98C379; base0D = 0x61AFEF } +} + +# Role -> SGR sequence, one entry per selectable theme. +# +# `default` is the odd one out and stays hand-written: it spells the roles as +# `$PSStyle`'s NAMED foregrounds, which emit ANSI 30-37 / 90-97. Those are +# palette-relative, so the terminal decides what they look like and the +# default rendering already follows whatever scheme the user's terminal is +# set to, on a light background as readily as a dark one. Every named theme +# instead burns in truecolor (`ESC[38;2;R;G;Bm`) and overrides that -- which +# is the whole point of asking for one, and why none is ever selected +# automatically. +# +# Neutral is deliberately absent from every truecolor theme. It marks a +# steady-state row carrying no verdict, so it has to stay readable on a light +# AND a dark background; any fixed hex loses one of the two. Omitting it +# falls through to uncolored, which inside a watch frame inherits the theme's +# own Foreground and outside one inherits the terminal's, both correct. +$Script:ThemePalettes = @{ + default = @{ + Heading = $PSStyle.Foreground.Yellow + Warning = $PSStyle.Foreground.BrightYellow + Success = $PSStyle.Foreground.BrightGreen + Danger = $PSStyle.Foreground.BrightRed + Muted = $PSStyle.Foreground.BrightBlack + Neutral = $PSStyle.Foreground.White + } +} +foreach ($schemeName in $Script:Base16Schemes.Keys) { + $Script:ThemePalettes[$schemeName] = New-ThemePalette -Scheme $Script:Base16Schemes[$schemeName] +} + +# The palette `Write-Color` renders through. Bound at load time, not inside +# Invoke-Main, because the test suite dot-sources this file and calls the +# Invoke-*Action bodies directly; that path never reaches Invoke-Main and +# would otherwise render through a $null palette. +$Script:Palette = $Script:ThemePalettes['default'] + +# Resolve a theme name to its palette. Unknown, unset or blank -> default. +# +# An unrecognized name falls back quietly instead of warning. A typo lives in +# a shell profile, so a warning would print on EVERY invocation for as long as +# it sits there -- louder and longer-lived than the cosmetic problem it +# reports. `-Verbose` surfaces it on demand, and `sca help` lists the names. +# +# Matching is case-insensitive for free: PowerShell's `@{}` literal builds a +# Hashtable with a case-insensitive comparer. +function Resolve-ThemePalette { + Param ([AllowEmptyString()] [AllowNull()] [String] $Name) + + $key = if ($Name) { $Name.Trim() } else { '' } + if (-not $key) { return $Script:ThemePalettes['default'] } + if ($Script:ThemePalettes.ContainsKey($key)) { return $Script:ThemePalettes[$key] } + + $known = ($Script:ThemePalettes.Keys | Sort-Object) -join ', ' + Write-Verbose "Unknown theme '$Name'; falling back to 'default'. Available: $known." + return $Script:ThemePalettes['default'] +} + +# The active theme's alt-screen chrome (background + base foreground) as one +# SGR run, or '' when the frame should keep the terminal's own colors. +# +# Chrome stops at the edge of the watch frame on purpose. `usage -Watch` and +# `monitor` own the whole alternate screen, so a background there reads as a +# deliberate canvas; every other action prints into the user's scrollback, +# where a background would leave ragged colored bars behind in their history +# for good. That is the entire reason a theme's Background is not simply a +# seventh role on Write-Color. +# +# Background and Foreground travel together. Painting a background without +# pinning a foreground would leave a light-terminal user reading their dark +# default text on our dark canvas. Inside the frame this pair becomes the +# effective default, which is also why `Neutral` must stay absent from the +# palette: it inherits the chrome foreground here and the terminal's +# foreground everywhere else, and both are right. +# +# The PlainText check is load-bearing and cannot be dropped as redundant. +# Chrome reaches the terminal through `Write-VTSequence` -> +# `[Console]::Out.Write`, which deliberately bypasses the `StringDecorated` +# filter that strips `Write-Color`'s SGR under `-NoColor` / `NO_COLOR`. Every +# other color path gets no-color mode for free; this one has to ask. +function Get-WatchChrome { + if ($PSStyle.OutputRendering -eq 'PlainText') { return '' } + + $bg = $Script:Palette['Background'] + if (-not $bg) { return '' } + + return $bg + $Script:Palette['Foreground'] +} + # Single chokepoint for ALL colored output. No production path may call # `Write-Host -ForegroundColor`. # @@ -1666,23 +1865,25 @@ function Show-Help { # effective at all: the toggle cannot reach the legacy `-ForegroundColor` # path, only SGR bytes in the stream. # -# Color name mapping: PowerShell legacy `ConsoleColor` and PS7's -# `$PSStyle.Foreground` use opposite naming conventions. Legacy -# "Dark*" names = the standard ANSI 30-37 colors; legacy un-prefixed -# names (Yellow, Green, Red...) = ANSI bright 90-97. So our existing -# `DarkYellow` (warm amber/mustard headers) maps to -# `$PSStyle.Foreground.Yellow` (ANSI 33), and `Yellow` (advisory) -# maps to `BrightYellow` (ANSI 93). Visually equivalent to the -# pre-refactor rendering on every modern terminal palette. +# Callers name a semantic ROLE, never a color, so a palette can change +# without touching any of the ~60 call sites. # # Palette convention. Not derivable from any single call site, so it is -# recorded once here; pick from this set rather than inventing a colour: -# DarkYellow : section-title headers ('[Usage] Plan usage', '[List] Saved -# slots'). Never a sentence. -# Yellow : advisories and warnings. "Attention required", never a header. -# Green : success on a side-effecting action ('[Save] Saved ...'). -# Red : destructive completion ('[Remove] Removed ...'). -# DarkGray : dimmed metadata (verbose account row, watch footer). +# recorded once here; pick from this set rather than inventing a role: +# Heading : section-title headers ('[Usage] Plan usage', '[List] Saved +# slots'). Never a sentence. +# Warning : advisories and warnings. "Attention required", never a header. +# Success : success on a side-effecting action ('[Save] Saved ...'). +# Danger : destructive completion ('[Remove] Removed ...'), and the +# at-or-over-cap end of the usage scale. +# Muted : dimmed metadata (verbose account row, watch footer). +# Neutral : a steady-state row carrying no verdict. +# An unknown role renders uncolored, which is also how a caller opts out +# deliberately by passing $null (Invoke-ListAction's inactive rows). +# +# Which SGR sequence renders a role is $Script:ThemePalettes' business; see +# its comment for why the default palette is palette-relative and a named +# theme is not. # # FORCE_COLOR is deliberately unsupported: Write-Host writes to the # information stream (6), not stdout, so a pipe or redirect never captures @@ -1694,16 +1895,10 @@ function Write-Color { [switch] $NoNewline ) - $sgr = switch ($Color) { - 'Yellow' { $PSStyle.Foreground.BrightYellow } - 'DarkYellow' { $PSStyle.Foreground.Yellow } - 'Green' { $PSStyle.Foreground.BrightGreen } - 'Red' { $PSStyle.Foreground.BrightRed } - 'Cyan' { $PSStyle.Foreground.BrightCyan } - 'Gray' { $PSStyle.Foreground.White } - 'DarkGray' { $PSStyle.Foreground.BrightBlack } - default { '' } - } + # Guarded rather than indexed straight through: a Hashtable throws on a + # $null index, and only the [String] coercion of $null to '' keeps that + # from firing on the deliberate `Write-Color $line $null` call sites. + $sgr = if ($Color) { $Script:Palette[$Color] } else { '' } if ($sgr) { $Message = "$sgr$Message$($PSStyle.Reset)" } @@ -1804,11 +1999,44 @@ function Get-WatchFrameText { # produces when the terminal lacks DEC 2026 or is too busy to honor it. # This is the ANSI equivalent of how PSReadLine / SetBufferContents repaint: # overwrite in place, never clear. +# +# -Chrome (from Get-WatchChrome, '' when the frame keeps the terminal's own +# colors) is woven in at three points, because a background is screen STATE +# rather than a property of any one string: +# 1. Once after ESC[H, so text written into the frame carries it. +# 2. Re-asserted after every ESC[0m in the body. Write-Color terminates +# each colored run with a full reset, which clears the background as +# well as the foreground; without this every colored row would punch a +# hole in the canvas from that point to the end of the line. +# 3. Immediately before each ESC[K and the trailing ESC[0J, so the erases +# fill with the theme background instead of the terminal's. +# Point 3 is the one that leans on the terminal: filling on erase is +# `back_color_erase`, which Windows Terminal, conhost, iTerm2, kitty, +# Alacritty, VTE and WezTerm all implement, but which is not universal. Where +# it is missing the written cells still carry the background and only the +# erased tail keeps the terminal's, so the frame degrades to a ragged right +# edge rather than breaking. Not probed, for the same reason truecolor is +# not: the capability databases are absent or wrong on Windows. function ConvertTo-WatchFrameSequence { - Param ([AllowEmptyString()] [AllowNull()] [string] $FrameText) + Param ( + [AllowEmptyString()] [AllowNull()] [string] $FrameText, + [AllowEmptyString()] [AllowNull()] [string] $Chrome + ) - $body = (($FrameText -split "`n") | ForEach-Object { $_ + "`e[K" }) -join "`n" - return "`e[H" + $body + "`e[0J" + if ($Chrome) { + $FrameText = $FrameText -replace "`e\[0m", "`e[0m$Chrome" + } + $body = (($FrameText -split "`n") | ForEach-Object { $_ + $Chrome + "`e[K" }) -join "`n" + $sequence = "`e[H" + $Chrome + $body + $Chrome + "`e[0J" + + # A line ending in a colored run gets chrome twice: once re-asserted after + # its ESC[0m, once again before its ESC[K. Collapsing runs of the same + # sequence is always safe (repeating an SGR is a no-op) and keeps a 1 Hz + # repaint from carrying ~35 redundant bytes per colored row. + if ($Chrome) { + $sequence = $sequence -replace "(?:$([regex]::Escape($Chrome)))+", $Chrome + } + return $sequence } # We are sanitizing names by replacing invalid characters with underscores, @@ -1858,7 +2086,7 @@ function Get-SafeName { } if ($clean -ne $inputName) { - Write-Color "Sanitized to: '$clean'" 'Yellow' + Write-Color "Sanitized to: '$clean'" 'Warning' } return $clean @@ -2042,7 +2270,7 @@ function Get-NextSlotName { } if ($slots.Count -eq 1 -and $activeIdx -eq 0) { - Write-Color "[Switch] Only one slot ($(Format-SlotIdentity -Name $slots[0].Name -Email $slots[0].Email)) and it is already active. Nothing to do." 'Yellow' + Write-Color "[Switch] Only one slot ($(Format-SlotIdentity -Name $slots[0].Name -Email $slots[0].Email)) and it is already active. Nothing to do." 'Warning' return $null } @@ -2093,7 +2321,7 @@ function Add-To-Profile { $encoding = Get-ProfileEncoding $ProfilePath Add-Content -LiteralPath $ProfilePath -Value $block -Encoding $encoding - Write-Color "[Install] Installed! Close and reopen PowerShell, then use: sca save " 'Green' + Write-Color "[Install] Installed! Close and reopen PowerShell, then use: sca save " 'Success' Write-Host " Quick ref: sca | sca -h | sca list | sca save | sca switch | sca remove " } @@ -2129,7 +2357,7 @@ function Remove-From-Profile { if (-not $hasStart -and -not $hasEnd) { if (-not $Quiet) { - Write-Color "[Uninstall] No Switch Claude Account block found; profile unchanged." 'Yellow' + Write-Color "[Uninstall] No Switch Claude Account block found; profile unchanged." 'Warning' } return } @@ -2161,7 +2389,7 @@ function Remove-From-Profile { Set-Content -LiteralPath $ProfilePath -Value $new -Encoding $encoding -Force -NoNewline if (-not $Quiet) { - Write-Color "[Uninstall] Uninstalled. Close and reopen PowerShell to remove the alias." 'Red' + Write-Color "[Uninstall] Uninstalled. Close and reopen PowerShell to remove the alias." 'Danger' } } @@ -2197,7 +2425,7 @@ function New-AutoSaveSlot { Write-Sidecar -SlotPath $autoPath -OAuthAccount $OAuthAccount -Source $SourceLabel } catch { - Write-Color "[Sync] Auto-save sidecar write failed for '$autoName': $($_.Exception.Message)" 'Yellow' + Write-Color "[Sync] Auto-save sidecar write failed for '$autoName': $($_.Exception.Message)" 'Warning' } } Update-ScaState -ActiveSlot $autoName -LastSyncHash $LastSyncHash | Out-Null @@ -2566,7 +2794,7 @@ function Invoke-Reconcile { $claudeJsonState = if ($identityError) { (Read-ClaudeJson).State } else { 'ok' } if ($identityError -and (($claudeJsonState -eq 'unreadable') -or ($newEmail -and $newEmail -ne $twin.Email))) { - Write-Color "[Sync] Active credentials match saved slot $twinIdent, but ~/.claude.json could not be pointed at it ($identityError), so the active slot is left as it was rather than split across the two files. Fix that and re-run, or run 'sca switch $($twin.Name)'." 'Yellow' + Write-Color "[Sync] Active credentials match saved slot $twinIdent, but ~/.claude.json could not be pointed at it ($identityError), so the active slot is left as it was rather than split across the two files. Fix that and re-run, or run 'sca switch $($twin.Name)'." 'Warning' return [pscustomobject]@{ Action = 'noop' Reason = 'adopt-identity-write-failed' @@ -2578,9 +2806,9 @@ function Invoke-Reconcile { } Update-ScaState -ActiveSlot $twin.Name -LastSyncHash $hash | Out-Null - Write-Color "[Sync] Active credentials match saved slot $twinIdent; tracking it as active." 'Yellow' + Write-Color "[Sync] Active credentials match saved slot $twinIdent; tracking it as active." 'Warning' if ($identityError) { - Write-Color "[Sync] ~/.claude.json was not updated ($identityError); Claude Code's /status email may lag until you run 'sca switch $($twin.Name)'." 'Yellow' + Write-Color "[Sync] ~/.claude.json was not updated ($identityError); Claude Code's /status email may lag until you run 'sca switch $($twin.Name)'." 'Warning' } return [pscustomobject]@{ @@ -2606,7 +2834,7 @@ function Invoke-Reconcile { } else { "so nothing was written. The next run that can resolve an identity will capture these credentials; if this persists while online, run 'sca save ' to capture them under a name you choose." } - Write-Color "[Sync] Active credentials changed but no account could be read from ~/.claude.json or /api/oauth/profile, $tail" 'Yellow' + Write-Color "[Sync] Active credentials changed but no account could be read from ~/.claude.json or /api/oauth/profile, $tail" 'Warning' return [pscustomobject]@{ Action = 'noop' Reason = 'identity-unresolved' @@ -2636,7 +2864,7 @@ function Invoke-Reconcile { } if ($verdict.Verdict -eq 'moved') { - Write-Color "[Sync] Active credentials changed while their account was being verified, so slot '$($state.active_slot)' is left untouched rather than risk filing one account's tokens under another's name. The next run reads them afresh." 'Yellow' + Write-Color "[Sync] Active credentials changed while their account was being verified, so slot '$($state.active_slot)' is left untouched rather than risk filing one account's tokens under another's name. The next run reads them afresh." 'Warning' return [pscustomobject]@{ Action = 'noop' Reason = 'credentials-changed-mid-probe' @@ -2654,7 +2882,7 @@ function Invoke-Reconcile { -LastSyncHash $hash $oldIdent = Format-SlotIdentity -Name $state.active_slot -Email $verdict.SlotEmail - Write-Color "[Sync] Active credentials are now $($verdict.Email); previous slot $oldIdent preserved. Active slot is now '$autoName'." 'Yellow' + Write-Color "[Sync] Active credentials are now $($verdict.Email); previous slot $oldIdent preserved. Active slot is now '$autoName'." 'Warning' return [pscustomobject]@{ Action = 'identity-change' Slot = $autoName @@ -2676,7 +2904,7 @@ function Invoke-Reconcile { -LastSyncHash $hash $autoIdent = Format-SlotIdentity -Name $autoName -Email $newEmail - Write-Color "[Sync] Auto-saved unknown active credentials as $autoIdent." 'Yellow' + Write-Color "[Sync] Auto-saved unknown active credentials as $autoIdent." 'Warning' return [pscustomobject]@{ Action = 'auto-save' Slot = $autoName @@ -2816,14 +3044,14 @@ function Invoke-SaveAction { # Read failure (file locked / unreadable). Mark non-restorable # but proceed with the save: refusing on a stale file the # user is explicitly overwriting would be surprising. - Write-Color "[Save] WARNING: could not snapshot $($rf.FullName) ($($_.Exception.Message)); rollback for this path will be skipped." 'Yellow' + Write-Color "[Save] WARNING: could not snapshot $($rf.FullName) ($($_.Exception.Message)); rollback for this path will be skipped." 'Warning' } if (Test-Path -LiteralPath $snap.SidecarPath) { try { $snap.SidecarBytes = [System.IO.File]::ReadAllBytes($snap.SidecarPath) } catch { - Write-Color "[Save] WARNING: could not snapshot $($snap.SidecarPath) ($($_.Exception.Message)); rollback for this path will be skipped." 'Yellow' + Write-Color "[Save] WARNING: could not snapshot $($snap.SidecarPath) ($($_.Exception.Message)); rollback for this path will be skipped." 'Warning' } } $snapshots += $snap @@ -2857,7 +3085,7 @@ function Invoke-SaveAction { Set-CredentialFileAtomic -Path $snap.Path -Bytes $snap.Bytes } catch { - Write-Color "[Save] WARNING: could not restore $($snap.Path) ($($_.Exception.Message))." 'Yellow' + Write-Color "[Save] WARNING: could not restore $($snap.Path) ($($_.Exception.Message))." 'Warning' } } if ($null -ne $snap.SidecarBytes) { @@ -2865,7 +3093,7 @@ function Invoke-SaveAction { Set-CredentialFileAtomic -Path $snap.SidecarPath -Bytes $snap.SidecarBytes } catch { - Write-Color "[Save] WARNING: could not restore $($snap.SidecarPath) ($($_.Exception.Message))." 'Yellow' + Write-Color "[Save] WARNING: could not restore $($snap.SidecarPath) ($($_.Exception.Message))." 'Warning' } } } @@ -2892,7 +3120,7 @@ function Invoke-SaveAction { Update-ScaState -ActiveSlot $safeName -LastSyncHash $hash | Out-Null $sourceTail = if ($sourceLabel -eq 'api_profile') { ' [identity from /api/oauth/profile]' } else { '' } - Write-Color "[Save] Saved as $(Format-SlotIdentity -Name $safeName -Email $email)$sourceTail" 'Green' + Write-Color "[Save] Saved as $(Format-SlotIdentity -Name $safeName -Email $email)$sourceTail" 'Success' } # Pure swap mechanism, factored out of Invoke-SwitchAction so the watch @@ -2940,8 +3168,8 @@ function Invoke-SlotSwap { Set-OAuthAccountInClaudeJson -OAuthAccount $Slot.Sidecar.oauthAccount } catch { - Write-Color "[Switch] Tokens swapped to '$($Slot.Name)' but ~/.claude.json oauthAccount update failed: $($_.Exception.Message)" 'Yellow' - Write-Color "[Switch] Claude Code's /status email may not reflect the new slot until you fix and re-run." 'Yellow' + Write-Color "[Switch] Tokens swapped to '$($Slot.Name)' but ~/.claude.json oauthAccount update failed: $($_.Exception.Message)" 'Warning' + Write-Color "[Switch] Claude Code's /status email may not reflect the new slot until you fix and re-run." 'Warning' } $hash = Get-SHA256Hex -Bytes $slotBytes @@ -2985,7 +3213,7 @@ function Invoke-SwitchAction { # emits no advisory; the slot table beneath the success line # makes the transition self-evident via the `*` marker. if (-not $rotation.HasActiveSlot) { - Write-Color "[Switch] No currently active slot detected. Rotating to $toIdent." 'Yellow' + Write-Color "[Switch] No currently active slot detected. Rotating to $toIdent." 'Warning' } } else { $safeName = Get-SafeName $Name @@ -3007,12 +3235,12 @@ function Invoke-SwitchAction { # even when the identity update fails. Invoke-SlotSwap -Slot $slot - # DarkYellow header line; matches the `[List] Saved slots` / + # Heading role; matches the `[List] Saved slots` / # `[Usage] Plan usage` convention so the table-rendering actions present a # consistent table-header look. No trailing period: this is a # header, not a complete sentence. $toIdent = Format-SlotIdentity -Name $slot.Name -Email $slot.Email - Write-Color "[Switch] Switched to $toIdent" 'DarkYellow' + Write-Color "[Switch] Switched to $toIdent" 'Heading' # Render the saved-slot table beneath the success line so the user # sees the new active slot in context (the `*` marker now points at @@ -3041,7 +3269,7 @@ function Invoke-ListAction { $slots = @(Get-Slots) if ($slots.Count -eq 0) { - Write-Color "[List] No slots saved yet. Use: sca save " 'Yellow' + Write-Color "[List] No slots saved yet. Use: sca save " 'Warning' return } @@ -3082,7 +3310,7 @@ function Invoke-RemoveAction { Remove-Item -LiteralPath $rf.FullName -Force Remove-Sidecar -SlotPath $rf.FullName } - Write-Color "[Remove] Removed '$safeName'" 'Red' + Write-Color "[Remove] Removed '$safeName'" 'Danger' } # --- usage action internals --- @@ -3274,9 +3502,12 @@ function Get-SlotOAuth { # message on failure. # # Race with a running Claude Code: `sca usage` does NOT refuse while -# Claude Code is running (only `save`, `warmup` and `monitor -KeepWarm` do; -# see Test-ClaudeRunning callers), so an active-slot refresh triggered here -# can race against Claude Code's own refresh. Anthropic rotates the +# Claude Code is running (only `save` does; see Test-ClaudeRunning), so an +# active-slot refresh triggered here can race against Claude Code's own. +# Claude Code serializes refreshes across its OWN processes behind a lock file +# and adopts a peer's result rather than racing it, but sca does not take that +# lock, so this call is not a participant and the race below is real where a +# `claude -p` would have none. Anthropic rotates the # refresh_token on every successful /v1/oauth/token call: whichever # party (sca or Claude Code) calls second presents the now-rotated old # token and gets a 4xx, losing its session. We accept this as a @@ -3425,7 +3656,7 @@ function Update-SlotTokens { # hash to compare against, must not be able to freeze the mirror. $liveHash = try { Get-SHA256Hex -Path $CredFile } catch { $null } if ($state.last_sync_hash -and $liveHash -and $liveHash -ne $state.last_sync_hash) { - Write-Color "[Sync] Token refreshed in slot '$($state.active_slot)' but .credentials.json holds bytes no slot has captured, so they were left alone rather than overwritten. Re-run once an account can be resolved, or run 'sca switch $($state.active_slot)' to propagate this slot's tokens deliberately." 'Yellow' + Write-Color "[Sync] Token refreshed in slot '$($state.active_slot)' but .credentials.json holds bytes no slot has captured, so they were left alone rather than overwritten. Re-run once an account can be resolved, or run 'sca switch $($state.active_slot)' to propagate this slot's tokens deliberately." 'Warning' } else { try { @@ -3446,7 +3677,7 @@ function Update-SlotTokens { # have rotated the refresh_token we just consumed; Claude # Code reading the stale .credentials.json could fail its # own next refresh and require re-login. - Write-Color "[Sync] Token refreshed in slot '$($state.active_slot)' but propagation to .credentials.json failed: $($_.Exception.Message). Run 'sca switch $($state.active_slot)' to propagate manually; otherwise Claude Code's own refresh may fail and require re-login." 'Yellow' + Write-Color "[Sync] Token refreshed in slot '$($state.active_slot)' but propagation to .credentials.json failed: $($_.Exception.Message). Run 'sca switch $($state.active_slot)' to propagate manually; otherwise Claude Code's own refresh may fail and require re-login." 'Warning' } } } @@ -3477,7 +3708,7 @@ function Update-SlotTokens { # ` (force propagation). $parsed = Get-SlotFileInfo -FileName ([System.IO.Path]::GetFileName($SlotPath)) if ($parsed -and $parsed.Name -eq $state.active_slot) { - Write-Color "[Sync] Token refreshed in slot '$($state.active_slot)' but its identity sidecar is missing, so propagation to .credentials.json was skipped. Run 'sca save $($state.active_slot)' to recapture the sidecar, or 'sca switch $($state.active_slot)' to force propagation now; otherwise Claude Code's own refresh may fail and require re-login." 'Yellow' + Write-Color "[Sync] Token refreshed in slot '$($state.active_slot)' but its identity sidecar is missing, so propagation to .credentials.json was skipped. Run 'sca save $($state.active_slot)' to recapture the sidecar, or 'sca switch $($state.active_slot)' to force propagation now; otherwise Claude Code's own refresh may fail and require re-login." 'Warning' } } } @@ -4574,31 +4805,37 @@ function Get-StatusColor { [bool] $IsActive ) - $okColor = if ($IsActive) { 'Green' } else { 'Gray' } + $okColor = if ($IsActive) { 'Success' } else { 'Neutral' } switch -Regex ($Label) { - '^limited' { return 'Red' } - '^near limit' { return 'Yellow' } + '^limited' { return 'Danger' } + '^near limit' { return 'Warning' } '^ok \(no plan' { return $okColor } '^ok$' { return $okColor } - '^no-oauth' { return 'DarkGray' } - '^expired' { return 'Yellow' } - '^unauthorized' { return 'Red' } - '^error' { return 'Red' } - '^rate-limited' { return 'Yellow' } + '^no-oauth' { return 'Muted' } + '^expired' { return 'Warning' } + '^unauthorized' { return 'Danger' } + '^error' { return 'Danger' } + '^rate-limited' { return 'Warning' } # 'warming up' is the transient monitor -KeepWarm queued state - # (slot not yet processed). Yellow matches its "attention + # (slot not yet processed). Warning matches its "attention # required" cousins (near limit, rate-limited, expired) so # the user immediately knows the row is in flight, not in # steady state. - '^warming up' { return 'Yellow' } + '^warming up' { return 'Warning' } # 'priming' is the per-slot in-flight state during the warmup # pass: Invoke-SlotActivator's `claude -p` call is running for # this row right now. Once the call completes, the row # transitions directly to a real status ('ok' / 'rate-limited' / # 'no-oauth' / 'expired' / 'unauthorized' / 'error'), which are # already mapped above. - '^priming$' { return 'Yellow' } - default { return 'Gray' } + '^priming$' { return 'Warning' } + # 'skipped' is terminal, unlike the two above: the pass aborted + # before reaching this row and will not come back to it. Muted + # rather than Warning because nothing about the row needs + # attention -- the abort advisory carries the whole story, and + # painting these yellow would compete with it. + '^skipped$' { return 'Muted' } + default { return 'Neutral' } } } @@ -4631,9 +4868,9 @@ function Get-StatusRationale { function Get-AggregateBarColor { Param ([int] $UsedPct) - if ($UsedPct -ge $Script:AggregateRedPct) { return 'Red' } - if ($UsedPct -ge $Script:AggregateYellowPct) { return 'Yellow' } - return 'Green' + if ($UsedPct -ge $Script:AggregateRedPct) { return 'Danger' } + if ($UsedPct -ge $Script:AggregateYellowPct) { return 'Warning' } + return 'Success' } # Compute the pool-mean utilization for a single bucket key across the @@ -4651,15 +4888,33 @@ function Get-AggregateBarColor { # list. # * $BucketKey - 'five_hour' or 'seven_day'. # +# The five_hour average covers reachable capacity only, so a row at the 7d +# hard cap ($Script:UtilLimitPct) leaves it entirely, numerator and +# denominator both: that slot serves no prompt until its week resets, and its +# idle 5h reading describes capacity nobody can spend. The number answers "of +# the session capacity I can still reach, how much is spent", which is why the +# row is dropped rather than scored 100 -- scoring it would answer "of nominal +# capacity, how much is gone", a different question the Week bar already +# covers. One-way on purpose: a capped 5h window costs the week at most 5h of +# 168, so the seven_day average keeps every measurable row, including one the +# week itself has capped, at its own number. +# +# The cost, accepted deliberately: this average improves as the pool dies. Two +# of three slots week-capped and the survivor idle reads 0%. The shrinking +# pool is signalled by the seven_day bar and the red rows, not here. +# # Return: # * Integer in [0, 100], rounded with [math]::Round, when at least one # eligible row exists. Math: sum of per-row utilization (each clamped # to [0,100]; null or missing counted as 0, which by Select-LiveBuckets # also covers a window that has rolled) divided by cap = N*100, scaled # to percent. Equivalently the mean utilization across all eligible rows. -# * $null when zero eligible rows. Callers decide what to render for -# the empty case (Format-AggregateBars emits nothing; Format-WatchTitle -# collapses to bare suffix). +# * 100 when rows are measurable but the week has capped every one of them: +# nothing is reachable, so the pool is spent. Returning $null there would +# blank the bar and the title at the moment they matter most. +# * $null when zero rows are measurable at all. Callers decide what to +# render for the empty case (Format-AggregateBars emits nothing; +# Format-WatchTitle collapses to bare suffix). function Get-PoolMeanUtilization { Param ( [object[]] $Results, @@ -4668,8 +4923,18 @@ function Get-PoolMeanUtilization { if (-not $Results) { return $null } - $eligible = @($Results | Where-Object { Test-RowIsMeasurable -Row $_ }) - if ($eligible.Count -eq 0) { return $null } + $measurable = @($Results | Where-Object { Test-RowIsMeasurable -Row $_ }) + if ($measurable.Count -eq 0) { return $null } + + # A capped week takes its slot out of the session pool; see the docblock. + $eligible = if ($BucketKey -eq 'five_hour') { + @($measurable | Where-Object { + (Get-BucketUtilizationOrZero -Bucket $_.Data.seven_day) -lt $Script:UtilLimitPct + }) + } else { + $measurable + } + if ($eligible.Count -eq 0) { return 100 } $n = $eligible.Count $cap = $n * 100 @@ -4735,6 +5000,10 @@ function Test-RowIsMeasurable { # Slot inclusion rules (Test-RowIsMeasurable): # * Status='ok', or any row carrying Data from the cache fallback. # * Buckets with null/missing utilization counted as 0% used. +# * A row at the 7d hard cap leaves the Session bar's average entirely, +# denominator included, because its session capacity is unreachable; the +# Week bar keeps it at its own 100%. See Get-PoolMeanUtilization for why +# the rule runs one way only, and for the all-capped case. # # Color thresholds via $Script:AggregateRedPct / $Script:AggregateYellowPct. # @@ -4835,6 +5104,9 @@ function Get-UsageStatusLabel { # limited' / 'limited 5h' / 'near limit'). 'warming-up' { 'warming up' } 'priming' { 'priming' } + # Terminal, set on the rows an aborted pass never reached, so the + # final table does not leave them claiming to be in flight. + 'skipped' { 'skipped' } default { [string]$Row.Status } } } @@ -4906,10 +5178,10 @@ function Measure-UsageTableColumns { # footer's [Monitor] line carries the same state. An unknown width (0) counts # as narrow. # -# Rendered as three -NoNewline segments so each carries its own SGR: white -# glyph (U+25B6, a high-contrast lozenge so the auto-mode signal pops) and -# DarkGray text (the footer's ambient-metadata weight, so the indicator -# recedes). The trailing blank Write-Host terminates the logical row. +# Rendered as three -NoNewline segments so each carries its own SGR: a +# Neutral glyph (U+25B6, a high-contrast lozenge so the auto-mode signal +# pops) and Muted text (the footer's ambient-metadata weight, so the +# indicator recedes). The trailing blank Write-Host terminates the row. function Write-UsageTableHeader { Param ([int] $AutoThreshold = 0) @@ -4931,12 +5203,12 @@ function Write-UsageTableHeader { } if ($glyph) { - Write-Color $headerLeft 'DarkYellow' -NoNewline - Write-Host $padding -NoNewline - Write-Color $glyph 'Gray' -NoNewline - Write-Color $text 'DarkGray' + Write-Color $headerLeft 'Heading' -NoNewline + Write-Host $padding -NoNewline + Write-Color $glyph 'Neutral' -NoNewline + Write-Color $text 'Muted' } else { - Write-Color $headerLeft 'DarkYellow' + Write-Color $headerLeft 'Heading' } Write-Host '' @@ -5034,7 +5306,7 @@ function Format-ListTable { # When set, skip the `[List] Saved slots` header and the leading # blank line. Used by Invoke-SwitchAction so the table renders # cleanly under the switch's own success line without a redundant - # second DarkYellow header. + # second Heading-role line. [switch] $SuppressHeader ) @@ -5064,14 +5336,14 @@ function Format-ListTable { $fmt = " {0} {1,-$nameW} {2}" if (-not $SuppressHeader) { - Write-Color "[List] Saved slots" 'DarkYellow' + Write-Color "[List] Saved slots" 'Heading' Write-Host '' } Write-Host ($fmt -f ' ', 'Slot', 'Account') Write-Host ($fmt -f ' ', ('-' * $nameW), ('-' * $acctW)) foreach ($entry in $rows) { - $color = if ($entry.Slot.IsActive) { 'Green' } else { $null } + $color = if ($entry.Slot.IsActive) { 'Success' } else { $null } if ($color) { Write-Color ($fmt -f $entry.Marker, $entry.Name, $entry.Account) $color } else { @@ -5097,13 +5369,13 @@ function Format-UsageVerbose { Param ([object] $Result) $name = $Result.Name - Write-Color "[Usage] Slot '$name'$(if ($Result.IsActive) { ' (active)' })" 'DarkYellow' + Write-Color "[Usage] Slot '$name'$(if ($Result.IsActive) { ' (active)' })" 'Heading' # Surface the OAuth account email whenever we could resolve it, so the # verbose drill-down answers the "which account is this?" question # without forcing the user to cross-reference the table. if ($Result.PSObject.Properties['Email'] -and $Result.Email) { - Write-Color " Account: $($Result.Email)" 'DarkGray' + Write-Color " Account: $($Result.Email)" 'Muted' } if ($Result.Status -ne 'ok') { @@ -5111,7 +5383,7 @@ function Format-UsageVerbose { return } if (-not $Result.Data) { - Write-Color " (empty response)" 'DarkGray' + Write-Color " (empty response)" 'Muted' return } @@ -5149,7 +5421,7 @@ function Format-UsageVerbose { $seven = $Result.Data.seven_day if (-not $five -and -not $seven) { - Write-Color " No plan-usage data (account may not have a subscription, or has not made a live API call yet)." 'DarkGray' + Write-Color " No plan-usage data (account may not have a subscription, or has not made a live API call yet)." 'Muted' return } @@ -5454,7 +5726,7 @@ function Format-UsageAdvisory { # -Footer : optional string printed below the table / verbose view # for the watch-mode "Last poll" line. Multi-line # strings are split and each line rendered in the -# DarkGray information color. +# Muted information role. # -AutoThreshold : when set (1..100), append a right-aligned # '▶ switching slot at N%' indicator to the # `[Usage] Plan usage` header. Used by `sca monitor` @@ -5474,7 +5746,7 @@ function Format-UsageFrame { ) if (-not $Snapshot -or $Snapshot.NoSlots) { - Write-Color "[Usage] No slots saved yet. Use: sca save " 'Yellow' + Write-Color "[Usage] No slots saved yet. Use: sca save " 'Warning' if ($Footer) { Format-UsageFooter $Footer } return } @@ -5511,11 +5783,11 @@ function Format-UsageFrame { # for Format-UsageFrame; extracted so the watch loop and any future # footer-consumers share one wrapping policy. # -# -Footer : the [Watch] / [Monitor] lines (DarkGray). Kept as the first +# -Footer : the [Watch] / [Monitor] lines (Muted). Kept as the first # positional parameter so the existing positional call sites # (`Format-UsageFooter $Footer`) bind unchanged. -# -Advisory : optional usage advisory (Yellow), one line per condition. -# Leads the footer block, above the DarkGray footer lines, so the +# -Advisory : optional usage advisory (Warning), one line per condition. +# Leads the footer block, above the Muted footer lines, so the # warning stays visually distinct while grouping with the # per-frame status. Split on newlines like $Footer, because # several conditions (a throttled peer and an unreadable active @@ -5536,12 +5808,12 @@ function Format-UsageFooter { Write-Host "" if ($Advisory) { foreach ($line in ($Advisory -split "`r?`n")) { - Write-Color $line 'Yellow' + Write-Color $line 'Warning' } } if ($Footer) { foreach ($line in ($Footer -split "`r?`n")) { - Write-Color $line 'DarkGray' + Write-Color $line 'Muted' } } } @@ -5756,8 +6028,8 @@ function Invoke-UsageAction { # # Rotation needs the client to re-read .credentials.json when its cached token # misses, which opencode-claude-auth >= 1.5.4 and Claude Code >= 2.1.274 both -# do, so plain `monitor` runs beside either. -KeepWarm refuses a live Claude -# Code, guarded in Invoke-UsageWatch; see Test-ClaudeRunning. +# do, so `monitor` runs beside either with or without -KeepWarm; see +# Test-ClaudeRunning for what the keep-warm round-robin costs a live session. function Invoke-MonitorAction { Param ( [string] $Name, @@ -5775,16 +6047,14 @@ function Invoke-MonitorAction { # table with live percentages and exits. This is the automation of the # manual "switch to a slot, send one message" routine across all slots. # -# Refuses up front if Claude Code is already running (see Test-ClaudeRunning), -# and when the `claude` binary is not on PATH, since the activation IS -# `claude`. The original active slot is restored by Invoke-WarmAllSlots' -# finally block. Billable: ~$0.004 per slot on the pinned Haiku model. +# Refuses when the `claude` binary is not on PATH, since the activation IS +# `claude`. Runs beside a live Claude Code and warns when it finds one; see +# Test-ClaudeRunning for why that is a warning rather than a refusal. The +# original active slot is restored by Invoke-WarmAllSlots' finally block. +# Billable: ~$0.004 per slot on the pinned Haiku model. function Invoke-WarmupAction { Param ([String] $Name) - if (Test-ClaudeRunning) { - throw "Claude Code is running. Close it before 'sca warmup', which makes every slot active in turn and would drag the live session across all of them. 'sca switch' and 'sca monitor' do not have that problem and run fine alongside Claude Code." - } if (-not (Get-Command claude -CommandType Application -ErrorAction SilentlyContinue)) { throw "The 'claude' CLI was not found on PATH. 'sca warmup' activates each slot by running 'claude -p', so Claude Code must be installed." } @@ -5798,21 +6068,57 @@ function Invoke-WarmupAction { throw (Get-UncapturedCredentialsRefusal -Sync $sync -ActionLabel 'sca warmup') } - Write-Color "[Warmup] Activating saved slots via 'claude -p' (billable; ~`$0.004/slot on Haiku, a few seconds each)..." 'DarkYellow' + # Sanitized once, here, and used for every lookup and message below. + # Get-SafeName advises when it changes the name, so resolving it at each + # call site would print that advisory once per site. + $safeName = if ($Name) { Get-SafeName $Name } else { $Name } + + # The pass makes each slot active in turn, so a live client follows it + # across every account before the finally restores the original. Warn + # rather than refuse: nothing here is destructive (Test-ClaudeRunning owns + # the evidence), but a prompt sent mid-pass bills whichever slot happens to + # be mounted, and only the user knows whether they are about to type one. + # + # Then wait, because the warning alone is not a decision: the first + # `claude -p` follows it by milliseconds, so a user reads it with the + # round-robin already under way. The pause is what makes the Ctrl-C it + # implies reachable. + # + # Both are held until a slot is known to match. A warning about what the + # round-robin will cost, followed by five seconds of Ctrl-C window, is a + # false alarm when the pass is about to report that it has nothing to + # activate: there is no decision to offer and nothing to abort. + if ((Get-WarmupSlotSet -Name $safeName).Count -gt 0 -and (Test-ClaudeRunning)) { + Write-Color $Script:WarmupLiveClientNotice 'Warning' + if ($Script:WarmupLiveClientPauseSec -gt 0) { + Write-Color "[Warmup] Starting in $($Script:WarmupLiveClientPauseSec)s; press Ctrl-C to abort." 'Warning' + Start-Sleep -Seconds $Script:WarmupLiveClientPauseSec + } + } + + Write-Color "[Warmup] Activating saved slots via 'claude -p' (billable; ~`$0.004/slot on Haiku, a few seconds each)..." 'Heading' # No-op repaint: the one-shot path has no live frame to redraw, so the # per-slot state transitions are not rendered; only the final snapshot # is printed below as the usual usage table. - $snapshot = Invoke-WarmAllSlots -Name $Name -Repaint { Param ($snap) } + # + # Re-resolved rather than reusing the set above: the preflight answered a + # yes/no question about the notice, and the pass owns the slots it acts on. + $snapshot = Invoke-WarmAllSlots -Name $safeName -Repaint { Param ($snap) } if ($null -eq $snapshot) { - $scope = if ($Name) { "matching '$(Get-SafeName $Name)'" } else { 'saved' } - Write-Color "[Warmup] No slots $scope to activate. Use: sca save " 'Yellow' + $scope = if ($safeName) { "matching '$safeName'" } else { 'saved' } + Write-Color "[Warmup] No slots $scope to activate. Use: sca save " 'Warning' return } + # Ahead of the table rather than after it: it says which account the user + # is left on, and a table of percentages is not what they need to read + # first when the answer is "not the one you started on". + if ($snapshot.Advisory) { Write-Color $snapshot.Advisory 'Warning' } + Write-Host '' - Format-UsageFrame -Name $Name -Snapshot $snapshot + Format-UsageFrame -Name $safeName -Snapshot $snapshot } # Decide whether the watch loop's -Auto mode should rotate, suggest a @@ -6279,6 +6585,24 @@ $Script:WarmupCooldownMin = 5 # warm, so nothing is sticky once the condition clears. $Script:WarmupBackoffMaxDoublings = 5 +# What a live Claude Code costs the warm round-robin, in one line, shared by +# `sca warmup` and `monitor -KeepWarm` so the two cannot describe the same +# hazard differently. Test-ClaudeRunning owns why this is a notice and not a +# refusal. One line because the watch renders it as a footer latch. +$Script:WarmupLiveClientNotice = "[Warmup] Claude Code is running. Each slot becomes active in turn and your session follows; a prompt sent during the pass bills whichever slot is mounted." + +# How long `sca warmup` waits after printing that notice before the first +# billable `claude -p`, so it is a decision point (Ctrl-C) rather than a label +# read once the pass is already under way. A prompt would be the stronger gate +# and was rejected: it needs a rule for a redirected stdin, and `sca warmup` +# is a command people put in a scheduler. +# +# The watch has no equivalent. Its round-robin is the thing the user asked +# for and it repeats for the life of the session, so a one-time pause would +# answer for a hazard that outlives it; the footer latch carries it instead. +# Tunable for tests (Common.ps1 overrides to zero). +$Script:WarmupLiveClientPauseSec = 5 + # Slot activator (`claude -p`) settings. Warmup opens a slot's 5h session # window by running the real Claude Code CLI as that slot, exactly as a # user typing one message would (Invoke-SlotActivator). This delegates the @@ -6314,41 +6638,83 @@ $Script:ActivatorTimeoutSec = 90 # OAuth refresh to Claude Code's own flow and is exactly what a user does # by hand. Cost: ~$0.004 per slot per warmup on the pinned Haiku model. # -# Mirror-then-verify (only after an 'ok' activation): +# Mirror-then-verify: # 1. Invoke-Reconcile copies the (possibly refreshed) tokens claude just -# wrote into .credentials.json back into the slot file. This MUST run -# before the usage read: otherwise Get-SlotUsage reads the slot's -# stale pre-activation token and triggers sca's own refresh against -# the (sometimes throttled) token endpoint -- the exact amplification -# this design removes. The reconcile takes the same-identity mirror -# branch (the swap wrote this slot's email to ~/.claude.json), so it -# never auto-saves. +# wrote into .credentials.json back into the slot file. Runs after EVERY +# activation, not only a successful one: the next iteration's swap +# overwrites that file, so a refresh claude landed before failing for +# some other reason (hitting the 5h limit is the common one) is destroyed +# unless it is captured here. It MUST also precede the usage read below, +# or Get-SlotUsage reads the slot's stale pre-activation token and +# triggers sca's own refresh against the (sometimes throttled) token +# endpoint -- the exact amplification this design removes. The reconcile +# takes the same-identity mirror branch (the swap wrote this slot's email +# to ~/.claude.json), so it never auto-saves, and it costs nothing when +# nothing moved: the swap stamped state.last_sync_hash with the bytes it +# wrote, so an untouched file returns at reconcile's hash-match check. # 2. Get-SlotUsage reads /api/oauth/usage with the fresh token so the # warmup frame shows live percentages immediately instead of -# 'ok (no plan data)' until the first poll ~60 s later. -# A failed activation (rate-limited / unauthorized / expired / no-oauth / -# error) skips both the mirror and the usage read -- no token to refresh, -# nothing to verify -- and surfaces the activator's own outcome, so a -# throttled slot incurs zero sca refresh calls. +# 'ok (no plan data)' until the first poll ~60 s later. Only after an +# 'ok' activation: a failed one has nothing to verify, and a throttled +# slot must incur zero sca refresh calls. # # Builds the rendered snapshot in place: one row per slot (filtered by # -Names, else -Name, when set), each starting at Status='warming-up' with Data=$null, # transitioning through 'priming' (the claude -p call in flight) to its -# real outcome. The end state is the first frame of the polling loop; the -# caller wires it to the watch session's Snapshot and stamps its LastPoll. +# real outcome, or to 'skipped' for the rows an abort below never reaches. +# The end state is the first frame of the polling loop; the caller wires it +# to the watch session's Snapshot and stamps its LastPoll. # Returns $null when no slots match. # # The original active slot is captured before the loop via Read-ScaState # + Find-SlotByName. A finally block restores it via one more Invoke-Slot- # Swap so a clean exit (or Ctrl-C, which still runs finally) returns the -# user where they started. Restore failure logs a yellow advisory naming -# the slot the user is now active on. No active slot captured (fresh -# install, sidecar-hidden active) is fine: the finally guard skips the -# restore and the user ends on the last activated slot. +# user where they started. No active slot captured (fresh install, +# sidecar-hidden active) is fine: the finally guard skips the restore and +# the user ends on the last activated slot. +# +# The pass stops the moment a reconcile cannot vouch for the bytes claude +# left active, and the restore is then skipped too, because it is one more +# overwrite of exactly those bytes. That and a failed restore are the two +# outcomes the user has to be told about, and both land on the returned +# snapshot's `Advisory` rather than on stdout; see the field. # # $Repaint is invoked as: & $Repaint $snapshot. The `claude -p` spawn # (seconds) naturally floors the 'priming' label's on-screen visibility, # so no artificial min-visibility sleep is needed. +# The saved slots a warm pass would target, for the same -Name / -Names the +# pass itself takes. Empty when nothing matches. +# +# Split out of Invoke-WarmAllSlots so a caller can ask the question BEFORE the +# pass starts. Invoke-WarmupAction is the one that needs to: its live-client +# warning is about activations that are going to happen, and the pause it adds +# is a decision point about spending money, so both have to stay quiet when the +# answer is that nothing will be activated at all. +# +# Get-SafeName runs here rather than in the callers because the filter is what +# needs the sanitized form. It is idempotent and only advises when it changes +# something, so a caller that sanitizes first (Invoke-WarmupAction, which needs +# the safe name for its own messages) pays for the advisory once rather than +# once per call site. +function Get-WarmupSlotSet { + Param ( + [string] $Name, + # Already-sanitized slot names, from Get-Slots output (snapshot rows). + # Takes precedence over -Name when both are supplied. + [string[]] $Names + ) + + $slots = @(Get-Slots) + if ($Names) { + return @($slots | Where-Object { $Names -contains $_.Name }) + } + if ($Name) { + $safe = Get-SafeName $Name + return @($slots | Where-Object { $_.Name -eq $safe }) + } + return $slots +} + function Invoke-WarmAllSlots { Param ( [string] $Name, @@ -6362,14 +6728,7 @@ function Invoke-WarmAllSlots { [Parameter(Mandatory)] [scriptblock] $Repaint ) - $slots = @(Get-Slots) - if ($Names) { - $slots = @($slots | Where-Object { $Names -contains $_.Name }) - } - elseif ($Name) { - $safe = Get-SafeName $Name - $slots = @($slots | Where-Object { $_.Name -eq $safe }) - } + $slots = @(Get-WarmupSlotSet -Name $Name -Names $Names) if ($slots.Count -lt 1) { return $null } # Each row carries IsCachedFallback / FallbackReason so the verify-after- @@ -6396,6 +6755,13 @@ function Invoke-WarmAllSlots { Results = $rows NoSlots = $false HasRateLimited = $false + # The one thing a caller must tell the user about where this pass left + # their credentials: it stopped early, or the restore failed. $null + # when neither happened. Carried on the snapshot rather than written + # here because both watch call sites suppress this function's + # information stream (6>$null), so a Write-Color would reach nobody + # there; each caller renders it on the surface it owns. + Advisory = $null } & $Repaint $snapshot @@ -6415,12 +6781,27 @@ function Invoke-WarmAllSlots { $lastSwapped = $origActive $last = $rows.Count - 1 + # Set when a reconcile could not vouch for the bytes claude left active. + # Separate from $snapshot.Advisory because it gates the restore below, + # which the restore's own failure message must not do. + $uncaptured = $false try { for ($i = 0; $i -le $last; $i++) { $row = $rows[$i] $row.Status = 'priming' & $Repaint $snapshot + # $activated is set the instant the swap succeeds, which is the + # instant `claude -p` may begin refreshing this slot's grant. It + # gates the capture check after the loop body: a swap that throws + # never reaches it and leaves .credentials.json exactly as the + # previous iteration captured it (Set-CredentialFileAtomic is an + # atomic rename, so a failed write is a no-op), which is why such a + # slot fails alone instead of stopping the pass. + $activated = $false + $sync = $null + $syncError = $null + # 6>$null suppresses [Switch] / [Sync] advisories so they # don't paint outside the DEC 2026 sync envelope. The try/ # catch catches Invoke-SlotSwap throws AND defends against @@ -6436,19 +6817,29 @@ function Invoke-WarmAllSlots { # Swap succeeded (it throws on failure): this slot is now # the live active slot. Record it for the restore advisory. $lastSwapped = $row - $r = Invoke-SlotActivator -SlotPath $row.Path 6>$null + $activated = $true + + try { + $r = Invoke-SlotActivator -SlotPath $row.Path 6>$null + } + finally { + # Capture whatever claude left in .credentials.json before + # the next iteration's swap overwrites it. Mirror-then- + # verify on the docblock owns why this runs on every + # outcome and why it must precede the usage read. + # + # In a finally because by the time anything above can throw, + # `claude -p` has already run and its refresh exists only in + # .credentials.json; the catch below would otherwise let the + # next swap discard it. Caught separately so a throw from + # the mirror's own atomic write neither replaces the + # activator's exception nor passes for a capture: $sync + # stays $null and the check below stops the pass. + try { $sync = Invoke-Reconcile 6>$null } + catch { $syncError = $_.Exception.Message } + } + if ($r.Status -eq 'ok') { - # Mirror claude's (possibly refreshed) tokens from - # .credentials.json back into the slot file BEFORE the - # verify read, so Get-SlotUsage reads the fresh token - # rather than the slot's stale pre-activation one (which - # would trigger sca's own refresh against a possibly - # throttled token endpoint). Same-identity mirror only; - # never auto-saves (the swap wrote this slot's email to - # ~/.claude.json). Then read usage so the warmup frame - # shows live percentages immediately instead of - # 'ok (no plan data)'. Neither call throws. - Invoke-Reconcile 6>$null | Out-Null # Drop any backoff stamp first: a successful activation is # evidence the throttle may be over, and the verify read # must probe live rather than be short-circuited by the @@ -6480,7 +6871,14 @@ function Invoke-WarmAllSlots { # and its own probe can be turned away before the server # looks at the grant, so this is the only way that command # can tell a dead login from a throttle. - if ($r.Status -in $Script:AuthVerdictStatuses) { + # + # Only when the reconcile above found the file untouched. A + # verdict asserts claude proved this grant dead, which holds + # only if claude wrote nothing: had its refresh gone through, + # the grant is alive and the refusal was about something + # else. Recording one then would strand a working slot + # behind a verdict that outlives the run. + if ($r.Status -in $Script:AuthVerdictStatuses -and $sync.Reason -eq 'hash-match') { Set-SlotAuthVerdict -SlotName $row.Name -SlotPath $row.Path ` -Status $r.Status -ErrorMessage $r.Error } @@ -6491,6 +6889,40 @@ function Invoke-WarmAllSlots { $row.Error = $_.Exception.Message } + # claude refreshed the grant, nothing mirrored it into a slot, and + # every write this pass has left -- the next iteration's swap and + # the restore below -- would discard it, leaving that slot holding a + # refresh token the server has already rotated. The one loss here + # no later pass can repair, so stop and leave the bytes in + # .credentials.json where `sca save` can still reach them. Read as + # a field rather than an Action allowlist, per Invoke-Reconcile's + # `Captured`; a $null $sync means the reconcile itself threw and + # proved nothing either way, which is equally unsafe to write over. + # + # Decided BEFORE the repaint below, which is the one statement in + # this loop body outside a catch. A renderer that throws unwinds + # straight to the finally, and $uncaptured is what stops the finally + # restoring over these bytes -- so leaving the decision until after + # it would let a repaint failure destroy exactly what the abort + # exists to keep. The break stays after, so the frame still shows + # the row the pass stopped on. + if ($activated -and (-not $sync -or -not $sync.Captured)) { + $uncaptured = $true + $detail = if ($syncError) { Format-StatusErrorTail -Message $syncError } + elseif ($sync) { "reconcile reported '$($sync.Reason)'" } + else { 'the reconcile returned nothing' } + $snapshot.Advisory = "[Warmup] Stopped at '$($row.Name)': nothing captured the credentials Claude Code left active ($detail), and warming on would discard a token refresh. You are active on '$($row.Name)'; close Claude Code and run 'sca save $($row.Name)' to keep them." + + # Give the rows this abort will never reach a terminal status. + # Left at their seeded 'warming-up' they read as in flight in a + # table the pass has already finished painting, and + # Invoke-KeepWarmStep charges a failed-warm backoff to every + # outcome that is not 'ok' -- including slots it never entered. + # An index loop, not a range: ($i + 1)..$last counts DOWN when + # the abort lands on the last row. + for ($j = $i + 1; $j -le $last; $j++) { $rows[$j].Status = 'skipped' } + } + # Recomputed per slot rather than once at the end so the flag is # already accurate at each repaint, and on an early return. # Format-UsageAdvisory partitions the rows itself and needs nothing @@ -6498,15 +6930,21 @@ function Invoke-WarmAllSlots { $snapshot.HasRateLimited = (@($rows | Where-Object { $_.Status -eq 'rate-limited' }).Count -gt 0) & $Repaint $snapshot + if ($uncaptured) { break } + if ($i -lt $last -and $Script:WarmupSpacingMs -gt 0) { Start-Sleep -Milliseconds $Script:WarmupSpacingMs } } } finally { - if ($origActive) { + # The restore is itself a .credentials.json overwrite, so it is exactly + # what the abort above exists to prevent; skipping it is what leaves + # the uncaptured bytes reachable. That Advisory already names the slot + # the user is left on, so nothing is repeated here. + if ($origActive -and -not $uncaptured) { try { Invoke-SlotSwap -Slot $origActive 6>$null } - catch { Write-Color "[Warmup] Restore of original active slot '$($origActive.Name)' failed: $($_.Exception.Message). You are now active on '$($lastSwapped.Name)'." 'Yellow' 6>$null } + catch { $snapshot.Advisory = "[Warmup] Restore of original active slot '$($origActive.Name)' failed: $(Format-StatusErrorTail -Message $_.Exception.Message). You are now active on '$($lastSwapped.Name)'." } } } return $snapshot @@ -6583,11 +7021,10 @@ function Get-WarmupCooldownMinutes { # $CooldownMin for the life of the watch. Optional: omitted (tests, one-shot # callers) means no slot has failed yet, which is the flat-cooldown behaviour. # -# Re-checks Test-ClaudeRunning per tick, catching a Claude Code launched -# mid-watch that the pre-loop guard could not see. Re-warmed rows are NOT -# merged back into $Snapshot; the next poll re-reads /api/oauth/usage. Never -# throws: a warm-path exception surfaces as a '[Warmup] Re-warm failed! ...' -# line. +# Runs beside a live Claude Code; see Test-ClaudeRunning for why the round- +# robin no longer refuses one. Re-warmed rows are NOT merged back into +# $Snapshot; the next poll re-reads /api/oauth/usage. Never throws: a warm-path +# exception surfaces as a '[Warmup] Re-warm failed! ...' line. # -Threshold is mandatory rather than defaulted: it must be the SAME value # auto-rotation uses, and the caller always has it. A default here would let a # wiring mistake silently disable the at-limit skip instead of failing loudly. @@ -6640,10 +7077,6 @@ function Invoke-KeepWarmStep { return $CurrentLatch } - if (Test-ClaudeRunning) { - return '[Warmup] Re-warm refused! Claude Code is running.' - } - # Re-capture before the round-robin below overwrites .credentials.json once # per slot. Same window, and the same reason, as Invoke-AutoRotationStep's: # the poll reconciled before Get-UsageSnapshot, which then spent a full @@ -6671,11 +7104,35 @@ function Invoke-KeepWarmStep { foreach ($w in @($warmed.Results)) { if ($w.Name) { $outcome[$w.Name] = $w.Status } } foreach ($n in $cold) { + # 'skipped' is the one outcome that is not a verdict on the slot: + # the pass aborted before reaching it. Stamping it would hold it + # off for a cooldown it did not earn, and charging it a failure + # would double that cooldown again on the next abort, so a slot + # the pass never entered is left exactly as it was found. Nothing + # re-fires in a loop as a result: the reconcile above refuses the + # whole step for as long as the condition that aborted it holds. + if ($outcome[$n] -eq 'skipped') { continue } + $WarmupTimes[$n] = $now if ($outcome[$n] -eq 'ok') { $WarmupFailures.Remove($n) } else { $WarmupFailures[$n] = 1 + [int]$WarmupFailures[$n] } } - return "[Warmup] Re-warmed $list at $($now.ToString('HH:mm:ss'))" + + # Takes the latch over the re-warm line: the pass stopped early, or it + # left the user on a slot they did not choose, and either outranks a + # roll-call of what was warmed. The 6>$null above is why this has to + # come off the snapshot rather than off stdout. + if ($warmed.Advisory) { return $warmed.Advisory } + + $latch = "[Warmup] Re-warmed $list at $($now.ToString('HH:mm:ss'))" + + # Re-tested per re-warm rather than once at startup: a watch runs for + # hours, and a client opened at hour three is dragged across every + # account by the very next pass. Prepended rather than replacing the + # line, so the latch still says which slots moved; Format-UsageFooter + # splits on the newline and renders both. + if (Test-ClaudeRunning) { return "$Script:WarmupLiveClientNotice`n$latch" } + return $latch } catch { # Stamp the attempt anyway so a hard failure does not re-fire every @@ -6723,16 +7180,14 @@ function Test-WatchInteractive { # lines instead of the entire loop body. Enter- returns the token Exit- # consumes; nothing else may read it. # -# Enter-'s read of [Console]::CursorVisible and its write are both guarded: -# neither is reliable off an attached Windows console, and an unguarded read -# aborted the whole watch engine at startup on Linux and macOS. Exit- restores -# through the API only where the capture succeeded, which confines its own -# unguarded write to a console that already answered once. -# A $null Cursor means "not captured", -# and Exit-WatchTerminal skips the API restore on it rather than coercing -# $null to $false and leaving the user's cursor hidden. The ESC[?25h in the -# alt-buffer leave is what the cursor actually depends on; the API call is -# belt-and-suspenders for the .NET-side state. +# Every [Console] call in both halves is guarded: none is reliable off an +# attached Windows console, and an unguarded read of CursorVisible once +# aborted the whole watch engine at startup on Linux and macOS. A $null +# Cursor means "not captured", and Exit-WatchTerminal skips the API restore +# on it rather than coercing $null to $false and leaving the user's cursor +# hidden. The ESC[?25h in the alt-buffer leave is what the cursor actually +# depends on; the API call is belt-and-suspenders for the .NET-side state, +# so failing it is not worth unwinding the caller's finally. # `docs/architecture.md` → *Console APIs*. # # The alt-buffer entry is the LAST mutation on purpose: it is the one that @@ -6765,6 +7220,14 @@ function Enter-WatchTerminal { # (atomic) repaint. Write-VTSequence "`e[?1049h`e[?25l" + # Paint the themed canvas once on entry. Without this the alt buffer + # shows the terminal's own background until the first frame lands, which + # on a slow first poll is a visible flash of the wrong color. A one-shot + # fill, not a per-frame clear, so it cannot reintroduce the flicker the + # ESC[2J ban exists to prevent. + $chrome = Get-WatchChrome + if ($chrome) { Write-VTSequence ($chrome + "`e[H`e[0J") } + return [pscustomobject]@{ Cursor = $origCursor Encoding = $origEncoding @@ -6792,7 +7255,9 @@ function Exit-WatchTerminal { Write-VTSequence ("`e]0;{0}`a" -f $restoreTitle) Write-VTSequence "`e[?25h`e[?1049l" } - if ($null -ne $State.Cursor) { [Console]::CursorVisible = $State.Cursor } + if ($null -ne $State.Cursor) { + try { [Console]::CursorVisible = $State.Cursor } catch { Write-Verbose "Cursor restore via console API not available: $_" } + } # Encoding last, after the alt-buffer leave and title restore have been # written through the UTF-8 writer (the original title may itself carry # non-ASCII). @@ -6988,7 +7453,7 @@ function Write-WatchFrame { Param ([Parameter(Mandatory)] [scriptblock] $RenderScript) $frameText = Get-WatchFrameText $RenderScript - Write-VTSequence ("`e[?2026h" + (ConvertTo-WatchFrameSequence $frameText) + "`e[?2026l") + Write-VTSequence ("`e[?2026h" + (ConvertTo-WatchFrameSequence -FrameText $frameText -Chrome (Get-WatchChrome)) + "`e[?2026l") } # The -Warmup startup pass, run once before the polling loop. Mutates @@ -7019,6 +7484,13 @@ function Invoke-WatchStartupWarm { throw (Get-UncapturedCredentialsRefusal -Sync $sync -ActionLabel 'sca monitor -KeepWarm') } + # Set before the pass so the very first frame carries it: this round-robin + # walks a live session across every saved account, and unlike `sca warmup` + # the watch cannot pause to say so. Overwrites New-WatchSession's seeded + # '[Warmup] Keeping all slots warm.', which describes the same activity + # without the part that costs the user money. + if (Test-ClaudeRunning) { $Session.WarmLatch = $Script:WarmupLiveClientNotice } + # -Auto's right-aligned "▶ switching slot at N%" header indicator stays # off when -Auto is absent. $autoHeader = if ($Auto) { $Threshold } else { 0 } @@ -7031,7 +7503,22 @@ function Invoke-WatchStartupWarm { Format-UsageFrame -Name $Name -Snapshot $snap -Footer $startupFooter -AutoThreshold $autoHeader } } - if ($null -eq $Session.Snapshot) { return } + # No slot matched, so the round-robin never ran. Drop both the live-client + # notice set above and New-WatchSession's seeded '[Warmup] Keeping all + # slots warm.': one warns about activations that will not happen, the + # other claims an activity there is nothing to perform it on. The frame + # below already says there are no slots. + if ($null -eq $Session.Snapshot) { + $Session.WarmLatch = $null + return + } + + # The pass suppresses nothing here, but its own advisories are written + # through Write-Color, which would paint outside the frame's sync + # envelope; the latch is the loop's channel for them. Overwrites the + # seeded '[Warmup] Keeping all slots warm.' because that claim is exactly + # what an advisory contradicts. + if ($Session.Snapshot.Advisory) { $Session.WarmLatch = $Session.Snapshot.Advisory } $Session.LastPoll = [DateTime]::Now try { @@ -7109,22 +7596,12 @@ function Invoke-UsageWatch { [switch] $Warmup ) - # Pre-loop Claude Code guard, -Warmup only; see Test-ClaudeRunning for why - # the fleet walk refuses and rotation does not. - # - # Checked BEFORE the Test-WatchInteractive guard so the user sees the - # more actionable "close Claude Code" message rather than the - # interactive-terminal one (which the test harness always hits). - if ($Warmup -and (Test-ClaudeRunning)) { - throw "Claude Code is running. Close it before 'sca monitor -KeepWarm', which makes every slot active in turn and would drag the live session across all of them. Plain 'sca monitor' rotates without that and runs fine alongside Claude Code." - } - if (-not (Test-WatchInteractive)) { throw "-Watch requires an interactive terminal; for scripted output use 'sca usage -Json'." } if ($Interval -lt $Script:UsageWatchMinInterval) { - Write-Color "[Usage] -Interval below minimum; clamping to $($Script:UsageWatchMinInterval)s (polite to the unofficial endpoint)." 'Yellow' + Write-Color "[Usage] -Interval below minimum; clamping to $($Script:UsageWatchMinInterval)s (polite to the unofficial endpoint)." 'Warning' $Interval = $Script:UsageWatchMinInterval } @@ -7164,7 +7641,7 @@ function Invoke-UsageWatch { # is set), so a single Format-UsageFooter call places # auto-mode state above the 'Waiting...' advisory; no # separate standalone print needed. - Write-Color "[Watch] Waiting for first successful /api/oauth/usage response..." 'Yellow' + Write-Color "[Watch] Waiting for first successful /api/oauth/usage response..." 'Warning' Format-UsageFooter $footer } } @@ -7196,9 +7673,16 @@ function Invoke-UsageWatch { # Precedence (most -> least specific): # 1. -NoColor switch (CLI flag) # 2. $env:NO_COLOR non-empty (https://no-color.org de facto standard) -# 3. default colored -# The previous $PSStyle.OutputRendering value is captured up-front and -# restored in the `finally` block so the toggle is scoped to this +# 3. $env:SCA_THEME names a palette +# 4. default colored +# NO_COLOR outranks SCA_THEME rather than conflicting with it: naming a +# theme says WHICH colors, not WHETHER, so it cannot re-enable color that +# was opted out of. The two are independent settings, and PlainText strips +# a theme's truecolor SGR by the same regex that strips the default +# palette's named SGR, so no-color mode needs no theme-specific handling. +# +# Both $PSStyle.OutputRendering and $Script:Palette are captured up-front +# and restored in the `finally` block so the toggles are scoped to this # invocation -- callers that dot-source this script (notably the test # suite, which calls Invoke-*Action directly and bypasses Invoke-Main) # are unaffected. @@ -7255,11 +7739,17 @@ function Invoke-Main { } $previousRendering = $PSStyle.OutputRendering + $previousPalette = $Script:Palette try { if ($NoColor -or -not [string]::IsNullOrEmpty($env:NO_COLOR)) { $PSStyle.OutputRendering = 'PlainText' } + # Resolved once per invocation rather than per Write-Color call: the + # environment cannot change mid-run, and a watch loop repaints the + # same roles hundreds of times. + $Script:Palette = Resolve-ThemePalette -Name $env:SCA_THEME + # Suppressed under -Json so scripted callers get nothing but the # document. Write-Host targets the information stream, which `|` and # `>` do not capture, so this is belt-and-suspenders rather than a @@ -7268,7 +7758,7 @@ function Invoke-Main { # before the frame takes over rather than fighting the repaint. if (-not $Json) { $configAdvisory = Get-ConfigDirAdvisory - if ($configAdvisory) { Write-Color $configAdvisory 'Yellow' } + if ($configAdvisory) { Write-Color $configAdvisory 'Warning' } } # Heals files a pre-4.0.0 sca wrote at the temp file's umask-default @@ -7285,7 +7775,7 @@ function Invoke-Main { if (-not $profileOnly) { $tightened = Repair-CredentialFileModes if ($tightened -gt 0 -and -not $Json) { - Write-Color "[Security] Tightened $tightened credential file(s) to 0600; they were readable by other users on this machine." 'Yellow' + Write-Color "[Security] Tightened $tightened credential file(s) to 0600; they were readable by other users on this machine." 'Warning' } } @@ -7303,6 +7793,7 @@ function Invoke-Main { } finally { $PSStyle.OutputRendering = $previousRendering + $Script:Palette = $previousPalette } } diff --git a/tests/Common.ps1 b/tests/Common.ps1 index c7daad3..748d6c9 100644 --- a/tests/Common.ps1 +++ b/tests/Common.ps1 @@ -72,7 +72,15 @@ Mock Invoke-RestMethod -ParameterFilter { # Default Test-ClaudeRunning mock: returns $false so save / switch don't # refuse to operate when no real claude.exe is in the test environment. # The few tests that exercise the running guard override this locally. -Mock Test-ClaudeRunning -MockWith { $false } +# +# A mock cannot be lifted once set, and this one replaces the very function +# whose body the Test-ClaudeRunning context needs to run. That context sets +# $script:ScaKeepRealClaudeRunning before dot-sourcing this file and mocks +# Get-Process instead; nothing else may, because every action that writes +# would then consult the developer's real process list. +if (-not $script:ScaKeepRealClaudeRunning) { + Mock Test-ClaudeRunning -MockWith { $false } +} # Collapse production sleep tunables to zero so the suite does not # spend real seconds inside mocked 429 paths. The retry logic is still @@ -80,9 +88,12 @@ Mock Test-ClaudeRunning -MockWith { $false } # because $Script:TokenRefreshRetryMax stays at its production value; # only the wall-clock wait between attempts goes to zero. Same trick # for $Script:WarmupSpacingMs so the warmup loop's per-slot 300 ms -# pacing does not multiply across many-slot tests. +# pacing does not multiply across many-slot tests, and for +# $Script:WarmupLiveClientPauseSec, which would otherwise add 5 real +# seconds to every test that lets Test-ClaudeRunning answer $true. $Script:TokenRefreshRetryDelayMs = 0 $Script:WarmupSpacingMs = 0 +$Script:WarmupLiveClientPauseSec = 0 # --- Test fixtures -------------------------------------------------------- diff --git a/tests/Helpers.Tests.ps1 b/tests/Helpers.Tests.ps1 index 7192aa2..6cb7c8c 100644 --- a/tests/Helpers.Tests.ps1 +++ b/tests/Helpers.Tests.ps1 @@ -46,6 +46,7 @@ BeforeAll { 'Format-WatchFooter' 'Write-WatchFrame' 'Invoke-WatchStartupWarm' + 'Get-WatchChrome' ) $ast = [System.Management.Automation.Language.Parser]::ParseFile( $Path, [ref]$null, [ref]$null) @@ -422,6 +423,101 @@ Describe 'switch_claude_account' { } } + Context 'Invoke-Main action dispatch' { + # The switch at the end of Invoke-Main is the only place that maps an + # action name to a body, and a typo in one arm is invisible to every + # other test in the suite: they all call the Invoke-*Action functions + # directly. Each case here mocks the destination and asserts the + # routing, which is the whole contract of the arm. + # + # Same dynamic-scope pattern as the two contexts above: assign the + # script's Param() variables in the It body and let Invoke-Main read + # them. + + It 'prints the help screen for the help action' { + $Action = 'help' + $out = (Invoke-Main 6>&1 | Out-String) + $out | Should -Match 'ACTIONS' + } + + # The data key is ActionName, not Action: a -ForEach key collides with + # the script's own [ValidateSet] $Action parameter, which is in scope + # here because the BeforeEach dot-sourced the script. Under the + # collision Pester expands to empty and the assignment never + # reaches Invoke-Main, so all eight cases fail identically. + It 'routes to ' -ForEach @( + @{ ActionName = 'install'; Target = 'Add-To-Profile' } + @{ ActionName = 'uninstall'; Target = 'Remove-From-Profile' } + @{ ActionName = 'save'; Target = 'Invoke-SaveAction' } + @{ ActionName = 'switch'; Target = 'Invoke-SwitchAction' } + @{ ActionName = 'list'; Target = 'Invoke-ListAction' } + @{ ActionName = 'remove'; Target = 'Invoke-RemoveAction' } + @{ ActionName = 'usage'; Target = 'Invoke-UsageAction' } + @{ ActionName = 'warmup'; Target = 'Invoke-WarmupAction' } + ) { + Mock -CommandName $Target -MockWith { } + $Action = $ActionName + Invoke-Main 6>$null + Should -Invoke -CommandName $Target -Times 1 -Exactly + } + + It 'passes -Name through to the dispatched action' { + Mock Invoke-SaveAction { } + $Action = 'save' + $Name = 'work' + Invoke-Main 6>$null + Should -Invoke Invoke-SaveAction -Times 1 -Exactly -ParameterFilter { $Name -eq 'work' } + } + + It 'emits the config-directory advisory when there is one' { + Mock Get-ConfigDirAdvisory { '[Config] CLAUDE_CONFIG_DIR is set; using somewhere else.' } + Mock Invoke-ListAction { } + $Action = 'list' + $out = (Invoke-Main 6>&1 | Out-String) + $out | Should -Match '\[Config\] CLAUDE_CONFIG_DIR is set' + } + + # -Json exists so a scripted caller gets nothing but the document, and + # the advisory is the one line emitted before the action body runs. + It 'suppresses the config-directory advisory under -Json' { + Mock Get-ConfigDirAdvisory { '[Config] CLAUDE_CONFIG_DIR is set; using somewhere else.' } + Mock Invoke-UsageAction { } + $Action = 'usage' + $Json = $true + $out = (Invoke-Main 6>&1 | Out-String) + $out | Should -Not -Match '\[Config\]' + } + + # Repair-CredentialFileModes is a no-op returning 0 on Windows, so the + # count is mocked rather than produced: the line under test is the + # report, and the repair itself is pinned in State-File.Tests.ps1. + It 'reports how many credential files the mode repair tightened' { + Mock Repair-CredentialFileModes { 2 } + Mock Invoke-ListAction { } + $Action = 'list' + $out = (Invoke-Main 6>&1 | Out-String) + $out | Should -Match '\[Security\] Tightened 2 credential file\(s\) to 0600' + } + + It 'stays silent about the mode repair when it changed nothing' { + Mock Repair-CredentialFileModes { 0 } + Mock Invoke-ListAction { } + $Action = 'list' + $out = (Invoke-Main 6>&1 | Out-String) + $out | Should -Not -Match '\[Security\]' + } + + # The dot-source guard at the foot of the file. Every other test in the + # suite dot-sources the script, which is exactly the case the guard + # suppresses, so nothing else proves `sca` runs anything at all when + # invoked as a script. -Version is the one action that reaches + # Invoke-Main and returns without touching disk or network. + It 'runs Invoke-Main when the script is invoked rather than dot-sourced' { + $out = (& $script:ScriptPath -Version 6>&1 | Out-String).Trim() + $out | Should -Match '^\d+\.\d+\.\d+$' + } + } + Context 'Format-WatchTitle' { # Pure string-builder for the OSC 0 watch-mode terminal title. # The title carries the active slot's two utilization numbers + @@ -714,14 +810,17 @@ Describe 'switch_claude_account' { # Regression contrast: without -Aggregate the same snapshot # renders the active row (10% | 10%, see "ignores non-active # rows" test above). With -Aggregate it averages all three: - # 5h mean = (100+10+100)/3 = 70, 7d mean = (100+10+100)/3 = 70. + # 5h mean = (100+10+100)/3 = 70, 7d mean = (95+10+95)/3 = 67. + # The peers sit at 95% on the week rather than 100% so every row + # stays in both denominators; what the weekly cap does to the + # Session average is pinned separately at the end of this block. $snap = New-FakeSnapshot -Rows @( - @{ Name = 'a'; FiveUtil = 100; SevenUtil = 100 } + @{ Name = 'a'; FiveUtil = 100; SevenUtil = 95 } @{ Name = 'b'; FiveUtil = 10; SevenUtil = 10; IsActive = $true } - @{ Name = 'c'; FiveUtil = 100; SevenUtil = 100 } + @{ Name = 'c'; FiveUtil = 100; SevenUtil = 95 } ) Format-WatchTitle -Name '' -Snapshot $snap -Aggregate | - Should -Be '[~] 70% | 70% | Switch Claude Account' + Should -Be '[~] 70% | 67% | Switch Claude Account' } It '-Aggregate excludes HTTP-failure rows with no data from the mean' { @@ -743,13 +842,15 @@ Describe 'switch_claude_account' { # carrying last-known percentages. Format-UsageTable prints those # numbers and Get-RowMaxUtilization rotates on them, so the pool # mean has to see them too or the bars contradict the table right - # beneath them. Mean = (40+100)/2 = 70. + # beneath them. 5h = (40+100)/2 = 70, 7d = (40+40)/2 = 40. Row 'b' + # is kept under the weekly cap on purpose so this pins the + # cached-row rule and not the exclusion tested below. $snap = New-FakeSnapshot -Rows @( @{ Name = 'a'; FiveUtil = 40; SevenUtil = 40; IsActive = $true } - @{ Name = 'b'; Status = 'error'; FiveUtil = 100; SevenUtil = 100 } + @{ Name = 'b'; Status = 'error'; FiveUtil = 100; SevenUtil = 40 } ) Format-WatchTitle -Name '' -Snapshot $snap -Aggregate | - Should -Be '[~] 70% | 70% | Switch Claude Account' + Should -Be '[~] 70% | 40% | Switch Claude Account' } It '-Aggregate counts null buckets as 0 (denominator stays N)' { @@ -813,7 +914,7 @@ Describe 'switch_claude_account' { Should -Be '49% | 49% | Switch Claude Account' } - It '-Aggregate [!] wins over [~] when one bucket is at Red and the other at Yellow' { + It '-Aggregate [!] wins over [~] when one bucket is at Danger and the other at Warning' { $snap = New-FakeSnapshot -Rows @(@{ FiveUtil = 90; SevenUtil = 50; IsActive = $true }) Format-WatchTitle -Name '' -Snapshot $snap -Aggregate | Should -Be '[!] 90% | 50% | Switch Claude Account' @@ -835,6 +936,21 @@ Describe 'switch_claude_account' { Format-WatchTitle -Name '' -Snapshot $snap -Aggregate | Should -Be '[~] 89% | 89% | Switch Claude Account' } + + It '-Aggregate drops a 7d-capped row from the Session number only' { + # 5h = 40/1 = 40: row 'a' is at the weekly cap, so it leaves the + # Session average and row 'b' carries it alone. 7d = (100+20)/2 = + # 60 still counts both, because dropping 'a' there would hide the + # weekly exhaustion. Shares Get-PoolMeanUtilization with the bar + # above the table so the two cannot drift; the math itself is + # pinned in Invoke-UsageAction.Tests.ps1. + $snap = New-FakeSnapshot -Rows @( + @{ Name = 'a'; FiveUtil = 0; SevenUtil = 100; IsActive = $true } + @{ Name = 'b'; FiveUtil = 40; SevenUtil = 20 } + ) + Format-WatchTitle -Name '' -Snapshot $snap -Aggregate | + Should -Be '[~] 40% | 60% | Switch Claude Account' + } } Context 'Watch-mode VT control rendering' { @@ -1312,6 +1428,35 @@ Describe 'switch_claude_account' { Should -Match ([regex]::Escape("`e[?25h")) } + # Both restores are belt-and-suspenders for .NET-side state: the VT + # sequences above are what the user's terminal actually obeys. Neither + # may therefore unwind the caller's finally, which is the last thing + # standing between a crashed watch and a terminal left in the alt + # buffer with no cursor. + It 'Exit-WatchTerminal never lets a cursor restore failure escape' { + # The API restore runs only where the capture succeeded, i.e. off + # an attached Windows console. Under a redirected test host the + # setter throws instead, which is exactly the case being pinned. + $state = [pscustomobject]@{ + Cursor = $true; Encoding = $null; Title = $null; EnteredAlt = $false + } + { Exit-WatchTerminal -State $state } | Should -Not -Throw + } + + It 'Exit-WatchTerminal never lets an encoding restore failure escape' { + $orig = [Console]::OutputEncoding + try { + # Truthy, so the guard lets it through, but not an Encoding, so + # the assignment throws on conversion. + $state = [pscustomobject]@{ + Cursor = $null; Encoding = [pscustomobject]@{ NotAnEncoding = $true } + Title = $null; EnteredAlt = $false + } + { Exit-WatchTerminal -State $state } | Should -Not -Throw + } + finally { [Console]::OutputEncoding = $orig } + } + It 'Exit-WatchTerminal restores a captured console encoding and skips a null one' { $orig = [Console]::OutputEncoding try { @@ -1402,6 +1547,46 @@ Describe 'switch_claude_account' { } } + It 'Enter-WatchTerminal paints the canvas once when the theme asks for one' { + # Without this the alt buffer shows the terminal's own background + # until the first frame lands, which on a slow first poll is a + # visible flash of the wrong color. One-shot fill, not a clear: + # the ESC[2J ban that keeps the repaint flicker-free covers this + # function too, so the entry path must not smuggle one in. + $saved = $Script:Palette + $PSStyle.OutputRendering = 'Ansi' + try { + $Script:Palette = Resolve-ThemePalette -Name 'material' + Invoke-WithEnteredWatchTerminal { + Param ($term, $enterText) + $enterText | Should -Match "`e\[\?1049h`e\[\?25l" + $enterText | Should -Match "`e\[48;2;38;50;56m" + $enterText | Should -Match "`e\[0J" + $enterText | Should -Not -Match "`e\[2J" + } + } + finally { + $Script:Palette = $saved + $PSStyle.OutputRendering = 'PlainText' + } + } + + It 'Enter-WatchTerminal writes nothing beyond the entry when no theme asks for a canvas' { + $saved = $Script:Palette + $PSStyle.OutputRendering = 'Ansi' + try { + $Script:Palette = Resolve-ThemePalette -Name 'default' + Invoke-WithEnteredWatchTerminal { + Param ($term, $enterText) + $enterText | Should -Be "`e[?1049h`e[?25l" + } + } + finally { + $Script:Palette = $saved + $PSStyle.OutputRendering = 'PlainText' + } + } + It 'Enter-WatchTerminal reports the alt buffer as entered so the restore fires' { # Exit-WatchTerminal skips the whole restore on a falsy # EnteredAlt, which would strand the user in the alt buffer. @@ -1696,6 +1881,58 @@ Describe 'switch_claude_account' { Should -Invoke Invoke-WarmAllSlots -Times 0 -Exactly } + # `sca warmup` prints and pauses; the watch paints into an alt-screen + # buffer and cannot, so the footer latch is its only channel. + + It 'latches the live-client notice when Claude Code is running' { + Mock Test-ClaudeRunning -MockWith { $true } + $s = New-WatchSession -Warmup + Invoke-WatchStartupWarm -Session $s -Interval 300 -Threshold 95 + + $s.WarmLatch | Should -Match 'Claude Code is running' + $s.WarmLatch | Should -Match 'bills whichever slot is mounted' + } + + It 'keeps the seeded latch when no Claude Code is running' { + $s = New-WatchSession -Warmup + Invoke-WatchStartupWarm -Session $s -Interval 300 -Threshold 95 + $s.WarmLatch | Should -Be '[Warmup] Keeping all slots warm.' + } + + # Both latch values are claims about a round-robin. Neither survives + # discovering there is none: the notice warns about activations that + # will not happen, and the seed claims an activity with nothing to + # perform it on. The frame already says there are no slots. + It 'drops the latch entirely when no slot matched' -ForEach @( + @{ Case = 'live client'; Running = $true } + @{ Case = 'no client'; Running = $false } + ) { + Mock Test-ClaudeRunning -MockWith { $Running } + Mock Invoke-WarmAllSlots -MockWith { $null } + + $s = New-WatchSession -Warmup + Invoke-WatchStartupWarm -Session $s -Interval 300 -Threshold 95 + + $s.WarmLatch | Should -BeNullOrEmpty + } + + It 'latches the advisory when the pass stopped early' { + # Invoke-WarmAllSlots stops rather than overwrite bytes nothing + # captured, which leaves the user on a slot they did not choose. + Mock Invoke-WarmAllSlots -MockWith { + [pscustomobject]@{ + Results = @([pscustomobject]@{ Name = 'alpha' }) + NoSlots = $false + HasRateLimited = $false + Advisory = "[Warmup] Stopped at 'alpha': nothing captured the credentials Claude Code left active." + } + } + $s = New-WatchSession -Warmup + Invoke-WatchStartupWarm -Session $s -Interval 300 -Threshold 95 + + $s.WarmLatch | Should -Match "Stopped at 'alpha'" + } + It 'stamps the last-poll time so the loop redraws instead of re-polling' { # The pass already produced a frame; leaving LastPoll at MinValue # would make the loop's first iteration fire a second full poll @@ -1860,6 +2097,26 @@ Describe 'switch_claude_account' { ([regex]::Matches($out, [regex]::Escape("`e[?2026h"))).Count | Should -Be 3 } + # -Warmup front-loads one billable pass over every slot before the + # loop starts, so which flag reaches that call is the difference + # between `sca usage -Watch` costing nothing and costing ~$0.004 a + # slot. Both directions are pinned for that reason. + It 'runs the startup warm pass before the loop under -Warmup' { + Mock Invoke-WatchStartupWarm -MockWith { } + + Invoke-BoundedWatch -WatchArgs @{ Warmup = $true } | Out-Null + + Should -Invoke Invoke-WatchStartupWarm -Times 1 -Exactly + } + + It 'does not warm anything without -Warmup' { + Mock Invoke-WatchStartupWarm -MockWith { } + + Invoke-BoundedWatch | Out-Null + + Should -Invoke Invoke-WatchStartupWarm -Times 0 -Exactly + } + It 'polls on the first tick and not again inside the interval' { # The redraw cadence is 1 s and the poll cadence is -Interval; # conflating them would hammer the unofficial endpoint once a @@ -1951,7 +2208,7 @@ Describe 'switch_claude_account' { # Common.ps1 sets PlainText for the session. The captured # .Message carries raw SGR that [Console]::Out.Write would not # strip, so Get-WatchFrameText must drop it itself in PlainText. - $text = Get-WatchFrameText { Write-Color 'X' 'Red' } + $text = Get-WatchFrameText { Write-Color 'X' 'Danger' } $text | Should -Be "X`n" $text.Contains("`e[") | Should -BeFalse } @@ -1960,7 +2217,7 @@ Describe 'switch_claude_account' { $prev = $PSStyle.OutputRendering try { $PSStyle.OutputRendering = 'Ansi' - $text = Get-WatchFrameText { Write-Color 'X' 'Red' } + $text = Get-WatchFrameText { Write-Color 'X' 'Danger' } $text.Contains("`e[") | Should -BeTrue -Because 'color frames keep their SGR for [Console]::Out.Write' } finally { $PSStyle.OutputRendering = $prev @@ -2098,13 +2355,394 @@ Describe 'switch_claude_account' { } } - Context 'ConvertTo-ScaJsonString' { - # Note: the function's `if ($null -eq $Value) { return 'null' }` - # branch is defensive-dead. PowerShell binds $null to a [string] - # parameter as '', so external callers cannot exercise it; the - # one internal caller in Set-OAuthAccountInClaudeJson short- - # circuits before calling. We do NOT test that branch. + Context 'Theming (SCA_THEME)' { + # The palette indirection: Write-Color takes a ROLE and looks its SGR + # up in $Script:Palette, which Invoke-Main binds from $env:SCA_THEME. + # + # Common.ps1 forces OutputRendering=PlainText, which strips SGR before + # a test can see it, so every rendering assertion here flips to 'Ansi' + # and restores in a finally. $Script:Palette is restored the same way: + # it is script-scope state on the dot-sourced file, so a test that + # leaves it on 'material' would recolor the rest of the file's run. + BeforeEach { + $script:themeSgrRegex = "`e\[[0-9;]*m" + if (Test-Path Env:\SCA_THEME) { Remove-Item Env:\SCA_THEME } + if (Test-Path Env:\NO_COLOR) { Remove-Item Env:\NO_COLOR } + } + + It 'resolves an unset, empty or blank name to the default palette' { + $expected = $Script:ThemePalettes['default'] + (Resolve-ThemePalette -Name $null) | Should -Be $expected + (Resolve-ThemePalette -Name '') | Should -Be $expected + (Resolve-ThemePalette -Name ' ') | Should -Be $expected + } + + It 'resolves a known name case-insensitively and tolerates surrounding blanks' { + $expected = $Script:ThemePalettes['material'] + (Resolve-ThemePalette -Name 'material') | Should -Be $expected + (Resolve-ThemePalette -Name 'MATERIAL') | Should -Be $expected + (Resolve-ThemePalette -Name 'Material') | Should -Be $expected + (Resolve-ThemePalette -Name ' material ') | Should -Be $expected + } + + It 'falls back to default on an unknown name instead of throwing' { + # A typo lives in a shell profile, so it must never break a run. + { Resolve-ThemePalette -Name 'no-such-theme' } | Should -Not -Throw + (Resolve-ThemePalette -Name 'no-such-theme') | + Should -Be $Script:ThemePalettes['default'] + } + + It 'names the available themes on the verbose stream when a name misses' { + # Driven by $VerbosePreference rather than a -Verbose argument: + # Resolve-ThemePalette is a simple function, so it has no common + # parameters. The preference is how `sca -Verbose` + # actually reaches it, the script itself carrying CmdletBinding. + $saved = $VerbosePreference + try { + $VerbosePreference = 'Continue' + $v = Resolve-ThemePalette -Name 'no-such-theme' 4>&1 | + Where-Object { $_ -is [System.Management.Automation.VerboseRecord] } | + ForEach-Object { $_.Message } + $v | Should -Match 'no-such-theme' + $v | Should -Match 'default' + $v | Should -Match 'material' + } + finally { $VerbosePreference = $saved } + } + + It 'omits Neutral from every truecolor theme so it inherits the terminal foreground' { + # Neutral marks a steady-state row with no verdict, so it has to + # stay readable on a light AND a dark background. Any fixed hex + # loses one of the two; absence falls through to uncolored. + foreach ($name in $Script:ThemePalettes.Keys) { + if ($name -eq 'default') { continue } + $Script:ThemePalettes[$name].ContainsKey('Neutral') | + Should -BeFalse -Because "theme '$name' must leave Neutral to the terminal" + } + } + + It 'renders every non-Neutral role in a theme, so no role silently loses its color' { + foreach ($name in $Script:ThemePalettes.Keys) { + foreach ($role in 'Heading','Warning','Success','Danger','Muted') { + $Script:ThemePalettes[$name][$role] | + Should -Not -BeNullOrEmpty -Because "theme '$name' must define '$role'" + } + } + } + + It 'emits truecolor SGR under material and named SGR under default' { + $saved = $Script:Palette + $PSStyle.OutputRendering = 'Ansi' + try { + $Script:Palette = Resolve-ThemePalette -Name 'default' + $out = Write-Color 'H' 'Heading' 6>&1 | Out-String + $out | Should -Match "`e\[33m" -Because 'default Heading is ANSI 33, resolved by the terminal palette' + + $Script:Palette = Resolve-ThemePalette -Name 'material' + $out = Write-Color 'H' 'Heading' 6>&1 | Out-String + $out | Should -Match "`e\[38;2;130;170;255m" -Because 'material Heading is base0D #82AAFF' + } + finally { + $Script:Palette = $saved + $PSStyle.OutputRendering = 'PlainText' + } + } + + It 'leaves Neutral uncolored under material but colored under default' { + $saved = $Script:Palette + $PSStyle.OutputRendering = 'Ansi' + try { + $Script:Palette = Resolve-ThemePalette -Name 'default' + (Write-Color 'N' 'Neutral' 6>&1 | Out-String) | Should -Match "`e\[" + + $Script:Palette = Resolve-ThemePalette -Name 'material' + (Write-Color 'N' 'Neutral' 6>&1 | Out-String) | Should -Not -Match "`e\[" + } + finally { + $Script:Palette = $saved + $PSStyle.OutputRendering = 'PlainText' + } + } + + It 'still renders an unknown role uncolored under a theme' { + $saved = $Script:Palette + $PSStyle.OutputRendering = 'Ansi' + try { + $Script:Palette = Resolve-ThemePalette -Name 'material' + (Write-Color 'x' 'not-a-role' 6>&1 | Out-String) | Should -Not -Match "`e\[" + # $null is the deliberate opt-out at Invoke-ListAction's + # inactive rows; a Hashtable throws on a $null index, so this + # guards the [String] coercion Write-Color leans on. + { Write-Color 'x' $null 6>$null } | Should -Not -Throw + } + finally { + $Script:Palette = $saved + $PSStyle.OutputRendering = 'PlainText' + } + } + + It 'PlainText strips a theme truecolor SGR exactly as it strips a named one' { + # This is what lets NO_COLOR outrank SCA_THEME for free: the + # StringDecorated regex matches ESC[38;2;R;G;Bm just as it matches + # ESC[33m, so no-color mode needs no theme-specific handling. + $saved = $Script:Palette + try { + $Script:Palette = Resolve-ThemePalette -Name 'material' + $PSStyle.OutputRendering = 'PlainText' + $out = Write-Color 'payload' 'Heading' 6>&1 | Out-String + $out | Should -Not -Match "`e\[" + $out | Should -Match 'payload' + } + finally { $Script:Palette = $saved } + } + + It 'Get-WatchFrameText strips a theme truecolor SGR under PlainText' { + # The watch path strips SGR by hand (Console.Out.Write does no + # filtering), so its regex needs the same truecolor guard. + $saved = $Script:Palette + try { + $Script:Palette = Resolve-ThemePalette -Name 'material' + $PSStyle.OutputRendering = 'PlainText' + $text = Get-WatchFrameText { Write-Color 'X' 'Heading' } + $text | Should -Not -Match "`e\[" + $text | Should -Match 'X' + } + finally { $Script:Palette = $saved } + } + + It 'keeps layout byte-identical across themes once SGR is stripped' { + # The regression this guards: truecolor sequences are ~3x longer + # than named ones. If any renderer measured a COLORED string to + # compute padding, switching theme would shift every column. + $saved = $Script:Palette + $PSStyle.OutputRendering = 'Ansi' + Mock Get-ConsoleWidth { 100 } + try { + $rendered = foreach ($name in 'default','material') { + $Script:Palette = Resolve-ThemePalette -Name $name + $raw = Write-UsageTableHeader -AutoThreshold 95 6>&1 | Out-String + , ($raw -replace $script:themeSgrRegex, '') + } + $rendered[0] | Should -Be $rendered[1] -Because ( + 'padding must be computed on plain text, never on a colored string') + # Guard against a vacuous pass: the colored forms must differ, + # otherwise the strip above could be hiding a no-op. + $rendered[0] | Should -Match 'switching slot at 95%' + } + finally { + $Script:Palette = $saved + $PSStyle.OutputRendering = 'PlainText' + } + } + + It 'binds the palette from $env:SCA_THEME during dispatch and restores it on exit' { + $saved = $Script:Palette + Mock Invoke-ListAction { $script:capturedPalette = $Script:Palette } + try { + $script:capturedPalette = $null + $env:SCA_THEME = 'material' + $Action = 'list' + + Invoke-Main + + $script:capturedPalette | Should -Be $Script:ThemePalettes['material'] + $Script:Palette | Should -Be $saved + } + finally { + Remove-Item Env:\SCA_THEME -ErrorAction SilentlyContinue + $Script:Palette = $saved + } + } + + It 'lets NO_COLOR outrank SCA_THEME: a theme is which colors, not whether' { + $saved = $Script:Palette + Mock Invoke-ListAction { + $script:capturedRendering = $PSStyle.OutputRendering + $script:capturedPalette = $Script:Palette + } + $PSStyle.OutputRendering = 'Host' + try { + $env:SCA_THEME = 'material' + $env:NO_COLOR = '1' + $Action = 'list' + + Invoke-Main + + # The theme still resolves; PlainText is what suppresses it. + $script:capturedPalette | Should -Be $Script:ThemePalettes['material'] + $script:capturedRendering | Should -Be 'PlainText' + } + finally { + Remove-Item Env:\SCA_THEME -ErrorAction SilentlyContinue + Remove-Item Env:\NO_COLOR -ErrorAction SilentlyContinue + $Script:Palette = $saved + $PSStyle.OutputRendering = 'PlainText' + } + } + + It 'defines all seven base16 slots in every shipped scheme' { + foreach ($name in $Script:Base16Schemes.Keys) { + $s = $Script:Base16Schemes[$name] + foreach ($slot in 'base00','base03','base05','base08','base0A','base0B','base0D') { + $s.ContainsKey($slot) | Should -BeTrue -Because "scheme '$name' must define $slot" + $s[$slot] | Should -BeGreaterOrEqual 0 + $s[$slot] | Should -BeLessOrEqual 0xFFFFFF -Because "$name.$slot must be a 24-bit color" + } + } + } + + It 'keeps Danger actually red and Success actually green in every scheme' { + # The rule that disqualified github. base16 slots carry SYNTAX + # meaning, which usually but not always lines up with the ANSI + # meaning a status table needs: github's port puts orange in + # base08 and pale blue in base0B, so Danger would have rendered + # orange and Success blue and a glance at the table would have + # misread which slots were healthy. Hue-checked rather than + # eyeballed, so a scheme added later cannot reintroduce it. + function Get-Hue ([int] $Rgb) { + $r = (($Rgb -shr 16) -band 0xFF) / 255 + $g = (($Rgb -shr 8) -band 0xFF) / 255 + $b = ( $Rgb -band 0xFF) / 255 + $max = [Math]::Max($r, [Math]::Max($g, $b)) + $min = [Math]::Min($r, [Math]::Min($g, $b)) + $d = $max - $min + if ($d -eq 0) { return 0 } + $h = if ($max -eq $r) { 60 * (((($g - $b) / $d) % 6)) } + elseif ($max -eq $g) { 60 * ((($b - $r) / $d) + 2) } + else { 60 * ((($r - $g) / $d) + 4) } + if ($h -lt 0) { $h += 360 } + return [int][Math]::Round($h) + } + + foreach ($name in $Script:Base16Schemes.Keys) { + $s = $Script:Base16Schemes[$name] + + # Red wraps zero, so the band is expressed as two arcs. Wide + # enough to admit monokai's magenta-leaning #F92672 (338) and + # the several schemes sitting just under 360. + $hRed = Get-Hue $s.base08 + ($hRed -le 25 -or $hRed -ge 330) | Should -BeTrue -Because ( + "$name base08 is hue $hRed; Danger must read as red, not orange") + + # Lower bound 55 admits gruvbox's olive #B8BB26 (61), which is + # that theme's actual green rather than a mis-slotted yellow. + $hGreen = Get-Hue $s.base0B + ($hGreen -ge 55 -and $hGreen -le 170) | Should -BeTrue -Because ( + "$name base0B is hue $hGreen; Success must read as green, not blue") + } + } + + It 'builds one palette per scheme, plus the hand-written default' { + $expected = @($Script:Base16Schemes.Keys) + 'default' + ($Script:ThemePalettes.Keys | Sort-Object) | + Should -Be ($expected | Sort-Object) + } + + It 'documents SCA_THEME and its available names in the help screen' { + $out = Show-Help 6>&1 | Out-String + $out | Should -Match 'SCA_THEME' + $out | Should -Match 'material' + $out | Should -Match 'NO_COLOR' + } + } + + Context 'Watch chrome (themed alt-screen background)' { + # A theme may paint the alternate screen. The chrome is background + + # base foreground, applied ONLY inside the watch frame, never to the + # scrollback output of list / save / usage. + BeforeEach { + $script:savedPalette = $Script:Palette + $script:sgrRegex = "`e\[[0-9;]*m" + } + AfterEach { + $Script:Palette = $script:savedPalette + $PSStyle.OutputRendering = 'PlainText' + } + + It 'pairs Background with Foreground in every theme, or omits both' { + # Painting a background without pinning a foreground leaves a + # light-terminal user reading dark default text on a dark canvas. + foreach ($name in $Script:ThemePalettes.Keys) { + $t = $Script:ThemePalettes[$name] + $t.ContainsKey('Background') | Should -Be $t.ContainsKey('Foreground') -Because ( + "theme '$name' must declare Background and Foreground together") + } + } + + It 'yields no chrome for a theme that declares no Background' { + $PSStyle.OutputRendering = 'Ansi' + $Script:Palette = Resolve-ThemePalette -Name 'default' + Get-WatchChrome | Should -BeNullOrEmpty + } + + It 'yields background and foreground for a theme that declares them' { + $PSStyle.OutputRendering = 'Ansi' + $Script:Palette = Resolve-ThemePalette -Name 'material' + $chrome = Get-WatchChrome + $chrome | Should -Match "`e\[48;2;38;50;56m" # base00 background + $chrome | Should -Match "`e\[38;2;238;255;255m" # base05 foreground + } + + It 'yields no chrome under PlainText even for a themed palette' { + # The load-bearing guard. Chrome reaches the terminal through + # Write-VTSequence -> [Console]::Out.Write, which bypasses the + # StringDecorated filter that gives every Write-Color path + # no-color mode for free. Drop this check and -NoColor / NO_COLOR + # would paint a background anyway. + $PSStyle.OutputRendering = 'PlainText' + $Script:Palette = Resolve-ThemePalette -Name 'material' + Get-WatchChrome | Should -BeNullOrEmpty + } + + It 'builds the unthemed frame exactly as it did before chrome existed' { + # Regression pin for the default theme: an empty chrome must not + # perturb the sequence by so much as a byte. + $frame = "alpha`nbeta" + ConvertTo-WatchFrameSequence -FrameText $frame -Chrome '' | + Should -Be "`e[Halpha`e[K`nbeta`e[K`e[0J" + } + + It 're-asserts chrome after every reset so a colored row cannot punch a hole in the canvas' { + # Write-Color ends each run with ESC[0m, which clears background + # as well as foreground. Unasserted, the canvas would break from + # that point to the end of every colored line. + $chrome = '' + $frame = "$($PSStyle.Foreground.BrightRed)hot$($PSStyle.Reset)tail" + $seq = ConvertTo-WatchFrameSequence -FrameText $frame -Chrome $chrome + $seq | Should -Match "`e\[0mtail" + } + + It 'asserts chrome before every erase so the erases fill with the theme background' { + $chrome = '' + $seq = ConvertTo-WatchFrameSequence -FrameText "one`ntwo" -Chrome $chrome + # Every ESC[K and the trailing ESC[0J must be preceded by chrome. + [regex]::Matches($seq, "`e\[K").Count | Should -Be 2 + $seq | Should -Match "one`e\[K" + $seq | Should -Match "two`e\[K" + $seq | Should -Match "`e\[0J$" + $seq | Should -Match "^`e\[H" + } + + It 'collapses repeated chrome so a 1 Hz repaint carries no redundant bytes' { + $chrome = '' + $frame = "$($PSStyle.Foreground.BrightRed)hot$($PSStyle.Reset)" + $seq = ConvertTo-WatchFrameSequence -FrameText $frame -Chrome $chrome + $seq | Should -Not -Match '' + } + + It 'leaves frame layout byte-identical whether or not chrome is applied' { + # The same invariant the themed-foreground test pins, extended to + # the background: chrome is color, never geometry. + $frame = "$($PSStyle.Foreground.BrightRed)hot$($PSStyle.Reset) row`nplain row" + $with = ConvertTo-WatchFrameSequence -FrameText $frame -Chrome ( + $PSStyle.Background.FromRgb(0x263238) + $PSStyle.Foreground.FromRgb(0xEEFFFF)) + $bare = ConvertTo-WatchFrameSequence -FrameText $frame -Chrome '' + ($with -replace $script:sgrRegex, '') | Should -Be ($bare -replace $script:sgrRegex, '') + } + + } + Context 'ConvertTo-ScaJsonString' { It 'escapes embedded double-quotes, backslashes, and control characters' { ConvertTo-ScaJsonString -Value 'a "b" \ c' | Should -Be '"a \"b\" \\ c"' ConvertTo-ScaJsonString -Value "line1`nline2`tend" | Should -Be '"line1\nline2\tend"' @@ -2213,17 +2851,17 @@ Describe 'switch_claude_account' { } Context 'Get-StatusColor (uncovered branches)' { - It 'returns DarkGray for the no-oauth label' { - (Get-StatusColor -Label 'no-oauth' -IsActive $false) | Should -Be 'DarkGray' + It 'returns Muted for the no-oauth label' { + (Get-StatusColor -Label 'no-oauth' -IsActive $false) | Should -Be 'Muted' } - It 'returns Yellow for the rate-limited label' { - (Get-StatusColor -Label 'rate-limited' -IsActive $false) | Should -Be 'Yellow' + It 'returns Warning for the rate-limited label' { + (Get-StatusColor -Label 'rate-limited' -IsActive $false) | Should -Be 'Warning' } - It 'returns Gray for unknown labels (default arm)' { - (Get-StatusColor -Label 'something-new' -IsActive $false) | Should -Be 'Gray' - (Get-StatusColor -Label '' -IsActive $false) | Should -Be 'Gray' + It 'returns Neutral for unknown labels (default arm)' { + (Get-StatusColor -Label 'something-new' -IsActive $false) | Should -Be 'Neutral' + (Get-StatusColor -Label '' -IsActive $false) | Should -Be 'Neutral' } } @@ -2814,6 +3452,34 @@ Describe 'switch_claude_account' { } } + Context 'ConvertTo-UpdatedClaudeJson' { + # The transform reports "nothing to write" as $null, and its caller + # uses that to skip the write entirely. Skipping matters because every + # write is a read-modify-write race against Claude Code, which holds + # the lock and merges while sca does not: a no-op write is a chance to + # lose someone else's edit in exchange for nothing. + + It 'returns null when every whitelisted field already holds the new value' { + # Spaced exactly as the substitution would emit it, so a + # byte-identical result really is a no-op rather than a reformat. + $raw = '{"numStartups":1,"oauthAccount":{"emailAddress": "a@b.com"}}' + $oa = [pscustomobject]@{ emailAddress = 'a@b.com' } + + ConvertTo-UpdatedClaudeJson -Raw $raw -OAuthAccount $oa | Should -BeNullOrEmpty + } + + It 'returns the rewritten document when a field actually changes' { + $raw = '{"numStartups":1,"oauthAccount":{"emailAddress": "a@b.com"}}' + $oa = [pscustomobject]@{ emailAddress = 'c@d.com' } + + $updated = ConvertTo-UpdatedClaudeJson -Raw $raw -OAuthAccount $oa + + $updated | Should -Match 'c@d\.com' + # Everything outside the block is carried through untouched. + $updated | Should -Match '"numStartups":1' + } + } + # The contending writer is Claude Code, which holds ~/.claude.json.lock and # merges under it while sca does not, so only sca's side can lose a write. # Driven by mocking the transform and letting the mock move the file @@ -3086,6 +3752,41 @@ Describe 'switch_claude_account' { $out | Should -Match 'propagation to \.credentials\.json failed' $out | Should -Match 'propagation denied' } + + # The mirror is blocked only by a PROVEN divergence. A file that cannot + # be hashed proves nothing, and treating it as divergence would strand + # the active slot's refreshed tokens in the slot file while + # .credentials.json kept serving the expired ones. + It 'still mirrors when the live credentials file cannot be hashed' { + $credDir = Join-Path $script:SandboxHome '.claude' + New-Item -ItemType Directory -Path $credDir -Force | Out-Null + $credFile = Join-Path $credDir '.credentials.json' + + $slot = New-SlotPair -CredDir $credDir -Name 'active' -Email 'a@b.com' -Content (@{ + claudeAiOauth = @{ + accessToken = 'OLD' + refreshToken = 'RT' + expiresAt = [DateTimeOffset]::UtcNow.AddHours(-1).ToUnixTimeMilliseconds() + } + } | ConvertTo-Json -Compress) + Copy-Item -LiteralPath $slot -Destination $credFile -Force + Update-ScaState -ActiveSlot 'active' -LastSyncHash 'A_HASH_THAT_DIFFERS' | Out-Null + + Mock Invoke-RestMethod -ParameterFilter { $Uri -eq 'https://platform.claude.com/v1/oauth/token' } -MockWith { + return [pscustomobject]@{ access_token = 'NEW'; refresh_token = 'NEW-RT'; expires_in = 3600 } + } + # Only the live-file hash fails; the -Bytes form the mirror uses + # afterwards must still work. + Mock Get-SHA256Hex -ParameterFilter { $Path -eq $credFile } -MockWith { + throw [System.IO.IOException]::new('handle went away') + } + + $out = Update-SlotTokens -SlotPath $slot 6>&1 | Out-String + + $out | Should -Not -Match 'left alone rather than overwritten' + (Get-Content -LiteralPath $credFile -Raw | ConvertFrom-Json).claudeAiOauth.accessToken | + Should -Be 'NEW' + } } Context 'Update-SlotTokens (429 retry behavior)' { @@ -3306,23 +4007,23 @@ Describe 'switch_claude_account' { # Common.ps1 forces OutputRendering=PlainText so the SGR codes # Write-Color emits are stripped by PowerShell's host filter # before we see them. We can still verify the function does not - # throw on each color name (covers the switch arms) and that + # throw on each role (covers the switch arms) and that # NoNewline is honored. - It 'emits without throwing for every named color (covers BrightCyan branch)' { - foreach ($c in 'Yellow','DarkYellow','Green','Red','Cyan','Gray','DarkGray') { + It 'emits without throwing for every documented role' { + foreach ($c in 'Heading','Warning','Success','Danger','Muted','Neutral') { { Write-Color "test" $c 6>$null } | Should -Not -Throw } } - It 'tolerates an unknown color name via the default branch' { - { Write-Color "test" 'not-a-color' 6>$null } | Should -Not -Throw + It 'tolerates an unknown role via the default branch' { + { Write-Color "test" 'not-a-role' 6>$null } | Should -Not -Throw } It '-NoNewline switch is honored (single Write-Host call without a newline)' { # Capture stream 6 and verify the emitted line carries the # message text. PlainText stripping leaves the text intact. - $out = Write-Color 'sentinel-no-newline' 'Cyan' -NoNewline 6>&1 | Out-String + $out = Write-Color 'sentinel-no-newline' 'Neutral' -NoNewline 6>&1 | Out-String $out | Should -Match 'sentinel-no-newline' } } @@ -3675,6 +4376,79 @@ Describe 'switch_claude_account' { } } + Context 'Credential paths with no resolvable home' { + # Every derived path stays $null when neither CLAUDE_CONFIG_DIR nor the + # platform's home variable resolves, so `sca help` and `sca -Version` + # still run in a container or a systemd unit started without one. + # Join-Path's binder rejects a null base, so without the guards these + # assignments would abort at load with "Cannot bind argument to + # parameter 'Path'" before either command could name the variable to + # set. + # + # These bind at load, so the only way to drive them is to load the + # script again under a blanked environment, in-process. $HOME is + # ReadOnly rather than Constant, so -Force can blank it; it is also + # AllScope, which is why the restore is doubled below. + + BeforeEach { + $script:SavedHomeVariable = $HOME + $script:SavedUserProfile = $env:USERPROFILE + $script:SavedHomeEnv = $env:HOME + } + + AfterEach { + # Paired with each It's own finally. A failure between the blanking + # and the restore would otherwise point every later test in the run + # at a home directory that does not exist. + Set-Variable -Name HOME -Value $script:SavedHomeVariable -Force -Scope Global + $env:USERPROFILE = $script:SavedUserProfile + $env:HOME = $script:SavedHomeEnv + } + + It 'leaves every derived path null instead of throwing at load' { + try { + $env:USERPROFILE = '' + $env:HOME = '' + $env:CLAUDE_CONFIG_DIR = $null + Set-Variable -Name HOME -Value '' -Force -Scope Global + + { . $script:ScriptPath } | Should -Not -Throw + . $script:ScriptPath + + $CredDir | Should -BeNullOrEmpty + $CredFile | Should -BeNullOrEmpty + $StateFile | Should -BeNullOrEmpty + $ClaudeJsonPath | Should -BeNullOrEmpty + } + finally { + Set-Variable -Name HOME -Value $script:SavedHomeVariable -Force -Scope Global + $env:USERPROFILE = $script:SavedUserProfile + $env:HOME = $script:SavedHomeEnv + } + } + + # The refusal that stands in for the load-time throw. Assert-CredentialDir + # defaults to the script-scope $CredDir, which the reload above made + # null, so this is the production call path rather than an argument. + It 'refuses the actions that need a directory, naming the variables to set' { + try { + $env:USERPROFILE = '' + $env:HOME = '' + $env:CLAUDE_CONFIG_DIR = $null + Set-Variable -Name HOME -Value '' -Force -Scope Global + + . $script:ScriptPath + + { Assert-CredentialDir } | Should -Throw -ExpectedMessage '*CLAUDE_CONFIG_DIR*' + } + finally { + Set-Variable -Name HOME -Value $script:SavedHomeVariable -Force -Scope Global + $env:USERPROFILE = $script:SavedUserProfile + $env:HOME = $script:SavedHomeEnv + } + } + } + Context 'Show-Help FILES block' { It 'prints the paths this invocation actually uses' { $out = Show-Help 6>&1 | Out-String diff --git a/tests/Invoke-AutoRotation.Tests.ps1 b/tests/Invoke-AutoRotation.Tests.ps1 index ef89d1d..2aec3dc 100644 --- a/tests/Invoke-AutoRotation.Tests.ps1 +++ b/tests/Invoke-AutoRotation.Tests.ps1 @@ -428,6 +428,28 @@ Describe 'switch_claude_account' { $d.SuggestionBucket | Should -Be 'Session' } + # A pool that is out of room but has no future reset to name: every + # window has already rolled, or the endpoint returned none. The + # suggestion fields have to come back empty rather than carry a + # formatted default, because Invoke-AutoRotationStep switches on + # SuggestionResetsAt to choose between the "cooling down for " + # line and the generic one. + It 'no-eligible reports no reset time when nothing has a future one' { + $past = [DateTimeOffset]::UtcNow.AddMinutes(-30).ToString('o', [Globalization.CultureInfo]::InvariantCulture) + + $rows = @( + (New-Row -Name 'a' -IsActive $true -FiveUtil 100.0 -FiveResetsAt $past -SevenUtil 100.0), + (New-Row -Name 'b' -FiveUtil 100.0 -SevenUtil 100.0) + ) + $d = Get-AutoRotationDecision -Snapshot (New-Snapshot $rows) -Threshold 100 + + $d.Action | Should -Be 'no-eligible' + $d.FromName | Should -Be 'a' + $d.SuggestionName | Should -BeNullOrEmpty + $d.SuggestionBucket | Should -BeNullOrEmpty + $d.SuggestionResetsAt | Should -BeNullOrEmpty + } + It 'threshold uses max(5h, 7d): 5h=10, 7d=99, threshold=95 -> rotate' { $rows = @( (New-Row -Name 'a' -IsActive $true -FiveUtil 10.0 -SevenUtil 99.0), diff --git a/tests/Invoke-MonitorAction.Tests.ps1 b/tests/Invoke-MonitorAction.Tests.ps1 index 7ba8505..bb3c044 100644 --- a/tests/Invoke-MonitorAction.Tests.ps1 +++ b/tests/Invoke-MonitorAction.Tests.ps1 @@ -10,8 +10,8 @@ # contexts in Invoke-UsageAction.Tests.ps1. Here we cover the action-level # contract: that monitor maps to the engine with -Auto set, threads -Threshold # and -KeepWarm through, ignores a positional name, and surfaces the -# watch-engine guards. Plain `monitor` runs beside a live Claude Code; only -# -KeepWarm refuses it, so the Claude-Code guard here is -KeepWarm's alone. +# watch-engine guards. Both plain `monitor` and -KeepWarm run beside a live +# Claude Code, so neither asserts a Claude-Code refusal any more. # Per-test sandbox setup lives in tests/Common.ps1. BeforeAll { @@ -89,14 +89,22 @@ Describe 'switch_claude_account' { { Invoke-MonitorAction 6>$null } | Should -Throw -ExpectedMessage '*requires an interactive terminal*' } - It 'still refuses -KeepWarm when Claude Code is running, naming the flag' { - # Keep-warm makes every slot active in turn, so a live session - # would be dragged across every account. That guard stays, and it - # runs BEFORE IsOutputRedirected so this is safe interactively. + It 'does NOT refuse -KeepWarm when Claude Code is running either' { + # Keep-warm makes every slot active in turn, which used to refuse a + # live client. It no longer does: claude serializes refreshes across + # its own processes, and the loss that justified the guard was sca's + # own discarded mirror, fixed in Invoke-WarmAllSlots. With that guard + # gone the next one reached is IsOutputRedirected, exactly as for + # plain `monitor` above. Mock Test-ClaudeRunning -MockWith { $true } + if (-not [Console]::IsOutputRedirected) { + Set-ItResult -Skipped -Because 'Console stdout is not redirected; running this test would enter the alt-screen buffer and blank the terminal.' + return + } + { Invoke-MonitorAction -KeepWarm 6>$null } | - Should -Throw -ExpectedMessage '*Claude Code is running*sca monitor -KeepWarm*' + Should -Throw -ExpectedMessage '*requires an interactive terminal*' } It 'passes the Claude-Code guard then short-circuits on IsOutputRedirected' { diff --git a/tests/Invoke-Reconcile.Tests.ps1 b/tests/Invoke-Reconcile.Tests.ps1 index dac18df..abc15f0 100644 --- a/tests/Invoke-Reconcile.Tests.ps1 +++ b/tests/Invoke-Reconcile.Tests.ps1 @@ -456,6 +456,128 @@ Describe 'switch_claude_account' { $hash = Get-SHA256Hex -Bytes ([Text.Encoding]::UTF8.GetBytes('AAA')) Find-SlotByHash -Hash $hash | Should -BeNullOrEmpty } + + # An unreadable candidate must not fail the scan: reconcile calls this + # on every credentials-touching action, and one bad file would take the + # whole action down. What it costs is a detection, so the skip is + # traced. + It 'skips every slot it cannot hash instead of failing the scan' { + New-SlotPair -CredDir $script:CD -Name 'work' -Email 'alice@example.com' -Content 'AAA' | Out-Null + New-SlotPair -CredDir $script:CD -Name 'personal' -Email 'bob@example.com' -Content 'BBB' | Out-Null + + # Every candidate fails, so the result does not depend on the order + # Get-Slots returns them in: the loop has to survive both and trace + # both. The filter tests for a non-empty $Path rather than a + # non-null one, because an unbound [String] parameter arrives here + # as '' and would capture the -Bytes call that builds the needle. + Mock Get-SHA256Hex -ParameterFilter { -not [string]::IsNullOrEmpty($Path) } -MockWith { + throw [System.IO.IOException]::new('file is locked') + } + + $hash = Get-SHA256Hex -Bytes ([Text.Encoding]::UTF8.GetBytes('BBB')) + $records = @(Find-SlotByHash -Hash $hash -Verbose 4>&1) + + # 4>&1 merges the verbose records into the output stream; a match + # would arrive here as a slot object, and there must be none. + @($records | Where-Object { $_ -isnot [System.Management.Automation.VerboseRecord] }) | + Should -BeNullOrEmpty + @($records | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] }).Count | + Should -Be 2 + } + } + + # ----- the account probe ---------------------------------------------- + + Context 'Test-CredentialAccountMatch (no uuid to compare)' { + # accountUuid is the only field this compares. Without one on the + # sidecar there is nothing to be right or wrong about, so the answer is + # 'unknown' and no request is made: a slot saved before sidecars + # carried uuids must not cost a profile round trip on every reconcile. + + It 'answers unknown without probing when the sidecar is absent' { + $slot = New-SlotPair -CredDir $script:CD -Name 'work' -Email 'alice@example.com' -Content $script:CredsBody + Mock Get-SlotProfile -MockWith { throw 'must not be called' } + + $r = Test-CredentialAccountMatch -CredentialPath $slot -Sidecar $null + + $r.Status | Should -Be 'unknown' + $r.Reason | Should -Be 'sidecar-has-no-uuid' + Should -Invoke Get-SlotProfile -Times 0 + } + + It 'answers unknown without probing when the sidecar carries no uuid' { + $slot = New-SlotPair -CredDir $script:CD -Name 'work' -Email 'alice@example.com' -Content $script:CredsBody + Mock Get-SlotProfile -MockWith { throw 'must not be called' } + + $sidecar = [pscustomobject]@{ + oauthAccount = [pscustomobject]@{ accountUuid = ''; emailAddress = 'alice@example.com' } + } + $r = Test-CredentialAccountMatch -CredentialPath $slot -Sidecar $sidecar + + $r.Status | Should -Be 'unknown' + $r.Reason | Should -Be 'sidecar-has-no-uuid' + Should -Invoke Get-SlotProfile -Times 0 + } + } + + Context 'Confirm-TrackedSlotIdentity (degraded inputs)' { + # Get-Slots populates Sidecar on every slot it returns, so a slot + # object without one reaches here only from a caller that built it by + # hand. The fallbacks exist so that caller still gets an answer rather + # than an empty-equals-empty comparison, which is the one outcome that + # would mirror one account's tokens over another's. + It 'falls back to the slot Email and a null account when there is no sidecar' { + $slot = [pscustomobject]@{ + Name = 'work' + Email = 'alice@example.com' + Path = Join-Path $script:CD '.credentials.work(alice@example.com).json' + Sidecar = $null + } + $incoming = [pscustomobject]@{ + accountUuid = 'uuid-bob'; emailAddress = 'bob@example.com' + } + + $r = Confirm-TrackedSlotIdentity -Slot $slot -IncomingEmail 'bob@example.com' ` + -IncomingAccount $incoming -IncomingSource 'claude_json' ` + -CredentialPath (Join-Path $script:CD '.credentials.json') -Hash 'HASH' + + $r.Verdict | Should -Be 'differs' + $r.SlotEmail | Should -Be 'alice@example.com' + } + + # The probe answered about the file as it stood when it read it. If the + # bytes moved under us since, the caller's hash describes a login that + # is already gone, and 'moved' is what says so. An unhashable file is + # the same situation: we cannot show the bytes are still the ones asked + # about. + It 'reports moved when the credentials file can no longer be hashed' { + $credPath = Join-Path $script:CD '.credentials.json' + Set-Content -LiteralPath $credPath -Value $script:CredsBody -NoNewline + + $account = [pscustomobject]@{ + accountUuid = 'uuid-alice'; emailAddress = 'alice@example.com' + } + $slot = [pscustomobject]@{ + Name = 'work' + Email = 'alice@example.com' + Path = Join-Path $script:CD '.credentials.work(alice@example.com).json' + Sidecar = [pscustomobject]@{ oauthAccount = $account } + } + + Mock Test-CredentialAccountMatch -MockWith { + [pscustomobject]@{ Status = 'mismatch'; Email = 'carol@example.com'; AccountUuid = 'uuid-carol' } + } + Mock Get-SHA256Hex -ParameterFilter { $Path -eq $credPath } -MockWith { + throw [System.IO.IOException]::new('vanished mid-probe') + } + + $r = Confirm-TrackedSlotIdentity -Slot $slot -IncomingEmail 'alice@example.com' ` + -IncomingAccount $account -IncomingSource 'claude_json' ` + -CredentialPath $credPath -Hash 'WHATEVER' + + $r.Verdict | Should -Be 'moved' + $r.SlotEmail | Should -Be 'alice@example.com' + } } # ----- the /login window --------------------------------------------- diff --git a/tests/Invoke-SaveAction.Tests.ps1 b/tests/Invoke-SaveAction.Tests.ps1 index 13ced1c..5f8f607 100644 --- a/tests/Invoke-SaveAction.Tests.ps1 +++ b/tests/Invoke-SaveAction.Tests.ps1 @@ -258,6 +258,132 @@ Describe 'switch_claude_account' { } } + Context 'Invoke-SaveAction (rollback diagnostics)' { + # The snapshot and restore steps are best-effort by design: a stale + # file the user is explicitly overwriting must not be able to refuse + # the save, and one failed restore must not abort the others. What + # that costs is silence, so each failure prints a line naming the path + # it gave up on. These cases drive the four warnings. + + # A slot file that cannot be read is snapshotted as non-restorable. + # The re-save carries a different email, so the write lands on a new + # path and the unreadable file is only ever a rollback source. + It 'warns and proceeds when a pre-existing slot file cannot be snapshotted' -Skip:(-not $IsWindows) { + $oldSlot = Join-Path $script:CredDirPath '.credentials.work(old@example.com).json' + New-SlotPair -CredDir $script:CredDirPath -Name 'work' -Email 'old@example.com' -Content 'OLD' | Out-Null + Set-Content -LiteralPath $script:CredFilePath -Value 'NEW' -NoNewline + + # FileShare::None is the only portable way to make ReadAllBytes + # fail on a file that exists and is enumerable. POSIX has no + # mandatory locking, hence the Unix twin below. + $stream = [System.IO.File]::Open($oldSlot, 'Open', 'Read', 'None') + try { + $out = (Invoke-SaveAction -Name 'work' 6>&1 | Out-String) + } + finally { $stream.Dispose() } + + $out | Should -Match '\[Save\] WARNING: could not snapshot .*old@example\.com.*rollback for this path will be skipped' + Test-Path -LiteralPath (Join-Path $script:CredDirPath '.credentials.work(alice@example.com).json') | Should -BeTrue + } + + It 'warns and proceeds when a pre-existing slot file cannot be snapshotted (unreadable mode)' -Skip:$IsWindows { + $oldSlot = Join-Path $script:CredDirPath '.credentials.work(old@example.com).json' + New-SlotPair -CredDir $script:CredDirPath -Name 'work' -Email 'old@example.com' -Content 'OLD' | Out-Null + Set-Content -LiteralPath $script:CredFilePath -Value 'NEW' -NoNewline + + # Mode 000 denies the snapshot read but not the unlink, which the + # parent directory's permissions govern, so the save that this + # drives to a warning then deletes the path as an obsolete sibling. + # The restore is for the case where it survives; the Windows twin + # needs no such guard because FileShare::None blocks the delete too. + [System.IO.File]::SetUnixFileMode($oldSlot, [System.IO.UnixFileMode]::None) + try { + $out = (Invoke-SaveAction -Name 'work' 6>&1 | Out-String) + } + finally { + if (Test-Path -LiteralPath $oldSlot) { + [System.IO.File]::SetUnixFileMode($oldSlot, [System.IO.UnixFileMode]'UserRead, UserWrite') + } + } + + $out | Should -Match '\[Save\] WARNING: could not snapshot .*old@example\.com.*rollback for this path will be skipped' + Test-Path -LiteralPath (Join-Path $script:CredDirPath '.credentials.work(alice@example.com).json') | Should -BeTrue + } + + # The sidecar is snapshotted separately from its tokens file, so it + # has its own warning and its own way to fail. + It 'warns and proceeds when a pre-existing sidecar cannot be snapshotted' -Skip:(-not $IsWindows) { + $oldSidecar = Join-Path $script:CredDirPath '.credentials.work(old@example.com).account.json' + New-SlotPair -CredDir $script:CredDirPath -Name 'work' -Email 'old@example.com' -Content 'OLD' | Out-Null + Set-Content -LiteralPath $script:CredFilePath -Value 'NEW' -NoNewline + + $stream = [System.IO.File]::Open($oldSidecar, 'Open', 'Read', 'None') + try { + $out = (Invoke-SaveAction -Name 'work' 6>&1 | Out-String) + } + finally { $stream.Dispose() } + + $out | Should -Match '\[Save\] WARNING: could not snapshot .*account\.json.*rollback for this path will be skipped' + } + + It 'warns and proceeds when a pre-existing sidecar cannot be snapshotted (unreadable mode)' -Skip:$IsWindows { + $oldSidecar = Join-Path $script:CredDirPath '.credentials.work(old@example.com).account.json' + New-SlotPair -CredDir $script:CredDirPath -Name 'work' -Email 'old@example.com' -Content 'OLD' | Out-Null + Set-Content -LiteralPath $script:CredFilePath -Value 'NEW' -NoNewline + + [System.IO.File]::SetUnixFileMode($oldSidecar, [System.IO.UnixFileMode]::None) + try { + $out = (Invoke-SaveAction -Name 'work' 6>&1 | Out-String) + } + finally { + # Guarded for the reason given on the slot-file twin above. + if (Test-Path -LiteralPath $oldSidecar) { + [System.IO.File]::SetUnixFileMode($oldSidecar, [System.IO.UnixFileMode]'UserRead, UserWrite') + } + } + + $out | Should -Match '\[Save\] WARNING: could not snapshot .*account\.json.*rollback for this path will be skipped' + } + + # One mock covers both restore warnings: the same throw that fails the + # forward write fails each restore behind it. The action still reports + # the original failure, because a rollback that could not run does not + # change what went wrong. + It 'warns per path when the rollback writes themselves fail' { + New-SlotPair -CredDir $script:CredDirPath -Name 'work' -Email 'old@example.com' -Content 'OLD' | Out-Null + Set-Content -LiteralPath $script:CredFilePath -Value 'NEW' -NoNewline + + Mock Set-CredentialFileAtomic -MockWith { throw [System.Exception]::new('device not ready') } + + # Stream 6 goes to a file rather than the pipeline: the call throws, + # and a terminated pipeline yields nothing to Out-String. + $log = Join-Path $TestDrive 'save-rollback.log' + $thrown = $null + try { Invoke-SaveAction -Name 'work' 6> $log } catch { $thrown = $_ } + $out = Get-Content -LiteralPath $log -Raw + + $thrown | Should -Not -BeNullOrEmpty + $thrown.Exception.Message | Should -BeLike '*Save failed for slot*previous slot state*' + $out | Should -Match '\[Save\] WARNING: could not restore .*work\(old@example\.com\)\.json' + $out | Should -Match '\[Save\] WARNING: could not restore .*work\(old@example\.com\)\.account\.json' + } + + # Get-SlotProfile reports a status for every outcome but an Error only + # for some; the refusal has to name the status when that is all there + # is, rather than interpolating an empty string into the parentheses. + It 'names the profile status when the failed probe carried no error text' { + Remove-Item -LiteralPath $ClaudeJsonPath -Force -ErrorAction SilentlyContinue + Set-Content -LiteralPath $script:CredFilePath -Value '{"claudeAiOauth":{"accessToken":"sk-ant-oat-x"}}' -NoNewline + + Mock Get-SlotProfile -MockWith { + [pscustomobject]@{ Status = 'expired'; Email = $null; AccountUuid = $null; Error = $null } + } + + { Invoke-SaveAction -Name 'work' 6>$null } | + Should -Throw -ExpectedMessage '*/api/oauth/profile failed (expired)*' + } + } + Context 'Invoke-SaveAction (state file)' { It 'updates state.active_slot to the saved slot' { Set-Content -LiteralPath $script:CredFilePath -Value 'SAL' -NoNewline diff --git a/tests/Invoke-Tests.ps1 b/tests/Invoke-Tests.ps1 index ae748ff..855953b 100644 --- a/tests/Invoke-Tests.ps1 +++ b/tests/Invoke-Tests.ps1 @@ -17,8 +17,14 @@ Param ( # threshold, $result.Result becomes 'Failed' and the exit predicate # below honors it. Pass -CoverageThreshold 0 to disable the gate # without losing the printed summary (-SkipCoverage skips both). + # + # 97 rather than the ~98.6% a full Windows run reaches, because the + # headroom above it is what a new platform-conditional branch spends: + # coverage is measured on one OS, so the other's arms are never executed + # and each one added lowers the number without anything being untested. + # `docs/testing.md` → *The ceiling* has the residue this leaves. [ValidateRange(0, 100)] - [int] $CoverageThreshold = 90 + [int] $CoverageThreshold = 97 ) $ErrorActionPreference = 'Stop' diff --git a/tests/Invoke-UsageAction.Tests.ps1 b/tests/Invoke-UsageAction.Tests.ps1 index 76bc0cd..b006088 100644 --- a/tests/Invoke-UsageAction.Tests.ps1 +++ b/tests/Invoke-UsageAction.Tests.ps1 @@ -1137,6 +1137,19 @@ Describe 'switch_claude_account' { ($out.IndexOf('alpha')) | Should -BeLessThan ($out.IndexOf('HELLO-FROM-FOOTER')) } + # The no-slots frame is still a frame. In the watch loop it is the + # whole screen, so dropping the footer there would take the + # [Monitor] / [Watch] state lines with it and leave a user who has + # not saved a slot yet looking at one static sentence. + It 'Format-UsageFrame keeps the footer on the no-slots frame' { + $snap = [pscustomobject]@{ Results = @(); NoSlots = $true } + + $out = Format-UsageFrame -Snapshot $snap -Footer 'HELLO-FROM-FOOTER' 6>&1 | Out-String + + $out | Should -Match 'No slots saved yet' + $out | Should -Match 'HELLO-FROM-FOOTER' + } + It 'Format-UsageTable renders bucket percentages for a rate-limited row that carries cached data' { # A rate-limited row served from the (possibly stale) cache # fallback carries last-known Data; its numbers must show so the @@ -1410,6 +1423,21 @@ Describe 'switch_claude_account' { $out | Should -Match '(?m)^\s+Week\s*\[.*\]\s+100%\s*$' } + It 'drops a 7d-capped row from the Session bar but keeps it on the Week bar' { + # Keeps the rendered bars wired to Get-PoolMeanUtilization's + # exclusion, unit-tested on its own below. Row 'a' is at the + # weekly cap, so Session = 20/100 = 20% over row 'b' alone while + # Week = (100 + 0)/200 = 50% still counts both. One fixture, both + # directions of the rule. + $rows = @( + (New-OkRow -Name 'a' -FiveUtil 0 -SevenUtil 100) + (New-OkRow -Name 'b' -FiveUtil 20 -SevenUtil 0) + ) + $out = Format-AggregateBars -Results $rows -TotalLineWidth 70 6>&1 | Out-String + $out | Should -Match '(?m)^\s+Session\s*\[.*\]\s+20%\s*$' + $out | Should -Match '(?m)^\s+Week\s*\[.*\]\s+50%\s*$' + } + It 'each rendered bar line equals TotalLineWidth (fits to table edge)' { $rows = @( (New-OkRow -Name 'a' -FiveUtil 50 -SevenUtil 50) ) $w = 70 @@ -1606,6 +1634,88 @@ Describe 'switch_claude_account' { ) Get-PoolMeanUtilization -Results $rows -BucketKey 'five_hour' | Should -Be 90 } + + # A slot at the weekly hard cap serves no prompt until the week + # resets, so it leaves the Session average entirely, denominator + # included: the number reports reachable capacity, and that slot's + # idle 5h reading describes capacity nobody can spend. These six pin + # the exclusion, its boundary, the all-capped floor, and the two + # directions the rule does NOT run in. + + It 'drops a 7d-capped row from the Session average' { + # 5h = 20/1 = 20. Not 10 (which would keep row 'a' in the + # denominator at its idle 0%) and not 60 (which would score it + # 100 and answer a question about nominal rather than reachable + # capacity). The three candidate rules are distinguishable here. + $rows = @( + (New-OkRow -Name 'a' -FiveUtil 0 -SevenUtil 100) + (New-OkRow -Name 'b' -FiveUtil 20 -SevenUtil 0) + ) + Get-PoolMeanUtilization -Results $rows -BucketKey 'five_hour' | Should -Be 20 + } + + It 'drops a 7d-capped row that carries no five_hour bucket at all' { + # The missing-bucket path: once the week is capped it makes no + # difference whether the 5h bucket reads 0 or is absent, because + # the row is gone from the average either way. + $rows = @( + (New-OkRow -Name 'a' -SevenUtil 100) + (New-OkRow -Name 'b' -FiveUtil 20 -SevenUtil 0) + ) + Get-PoolMeanUtilization -Results $rows -BucketKey 'five_hour' | Should -Be 20 + } + + It 'returns 100 when the week has capped every measurable row' { + # Nothing is reachable, so the pool is spent. Must not be $null: + # that blanks the bar and the title at the moment they matter + # most, and it is the one case where this average is allowed to + # disagree with the direction of travel described above. + $rows = @( + (New-OkRow -Name 'a' -FiveUtil 0 -SevenUtil 100) + (New-OkRow -Name 'b' -FiveUtil 0 -SevenUtil 100) + ) + Get-PoolMeanUtilization -Results $rows -BucketKey 'five_hour' | Should -Be 100 + } + + It 'keeps a 7d-capped row in the Week average at its own number' { + # 7d = (100 + 20)/2 = 60, NOT 20. Excluding it here would hide + # weekly exhaustion, which is the signal the Week bar exists for + # and the reason the exclusion is confined to the Session bar. + $rows = @( + (New-OkRow -Name 'a' -FiveUtil 0 -SevenUtil 100) + (New-OkRow -Name 'b' -FiveUtil 0 -SevenUtil 20) + ) + Get-PoolMeanUtilization -Results $rows -BucketKey 'seven_day' | Should -Be 60 + } + + It 'leaves the Week average alone for a 5h-capped row (the rule is one-way)' { + # 7d = (20 + 0)/2 = 10. A capped 5h window costs the week at most + # 5h of 168, so row 'a' keeps its 80% of weekly headroom and its + # place in the denominator. Inverse-axis check: a symmetric rule + # would drop or score it and fail here. + $rows = @( + (New-OkRow -Name 'a' -FiveUtil 100 -SevenUtil 20) + (New-OkRow -Name 'b' -FiveUtil 0 -SevenUtil 0) + ) + Get-PoolMeanUtilization -Results $rows -BucketKey 'seven_day' | Should -Be 10 + } + + It 'excludes at UtilLimitPct (100) exactly, not one point below' { + # 99% of a week still leaves reachable session capacity, so the + # exclusion must not creep down into the 'near limit' tier. + # near: (0 + 40)/2 = 20, both rows counted. + # at: 40/1 = 40, row 'a' gone. + $near = @( + (New-OkRow -Name 'a' -FiveUtil 0 -SevenUtil 99) + (New-OkRow -Name 'b' -FiveUtil 40 -SevenUtil 0) + ) + $at = @( + (New-OkRow -Name 'a' -FiveUtil 0 -SevenUtil 100) + (New-OkRow -Name 'b' -FiveUtil 40 -SevenUtil 0) + ) + Get-PoolMeanUtilization -Results $near -BucketKey 'five_hour' | Should -Be 20 + Get-PoolMeanUtilization -Results $at -BucketKey 'five_hour' | Should -Be 40 + } } Context 'Get-AggregateBarColor' { @@ -1613,19 +1723,19 @@ Describe 'switch_claude_account' { # Runs the threshold boundaries explicitly so a future tweak of # $Script:AggregateRedPct / $Script:AggregateYellowPct shows up # here as a failing test rather than a silent visual change. - It 'returns Green below AggregateYellowPct (50%)' { - Get-AggregateBarColor -UsedPct 0 | Should -Be 'Green' - Get-AggregateBarColor -UsedPct 49 | Should -Be 'Green' + It 'returns Success below AggregateYellowPct (50%)' { + Get-AggregateBarColor -UsedPct 0 | Should -Be 'Success' + Get-AggregateBarColor -UsedPct 49 | Should -Be 'Success' } - It 'returns Yellow between AggregateYellowPct (50%) and AggregateRedPct-1 (89%)' { - Get-AggregateBarColor -UsedPct 50 | Should -Be 'Yellow' - Get-AggregateBarColor -UsedPct 89 | Should -Be 'Yellow' + It 'returns Warning between AggregateYellowPct (50%) and AggregateRedPct-1 (89%)' { + Get-AggregateBarColor -UsedPct 50 | Should -Be 'Warning' + Get-AggregateBarColor -UsedPct 89 | Should -Be 'Warning' } - It 'returns Red at and above AggregateRedPct (90%)' { - Get-AggregateBarColor -UsedPct 90 | Should -Be 'Red' - Get-AggregateBarColor -UsedPct 100 | Should -Be 'Red' + It 'returns Danger at and above AggregateRedPct (90%)' { + Get-AggregateBarColor -UsedPct 90 | Should -Be 'Danger' + Get-AggregateBarColor -UsedPct 100 | Should -Be 'Danger' } } @@ -2584,6 +2694,28 @@ Describe 'switch_claude_account' { $Script:SlotUsageCache[$script:boSlot].ContainsKey('RateLimitedUntil') | Should -BeFalse } + # The backoff suppresses HTTP for RateLimitBackoffSec, but the entry it + # serves instead can be arbitrarily older than that. Past the age + # ceiling the suppression still applies, because the point is not to + # re-trip a hot limiter, but the numbers stop being shown: a row that + # renders em-dashes must not also claim to be showing last known usage. + It 'stops serving cached numbers past the age ceiling but still suppresses HTTP' { + $script:staleCount = 0 + Mock Invoke-RestMethod -MockWith { $script:staleCount++; throw 'HTTP must not be called during backoff' } + $Script:SlotUsageCache[$script:boSlot] = @{ + Data = [pscustomobject]@{ five_hour = [pscustomobject]@{ utilization = 7.0 } } + Timestamp = [DateTime]::UtcNow.AddMinutes(-($Script:UsageCacheMaxAgeMin + 1)) + RateLimitedUntil = [DateTime]::UtcNow.AddSeconds(120) + } + + $r = Get-SlotUsage -SlotPath $script:boSlot + + $r.Status | Should -Be 'rate-limited' + $r.Data | Should -BeNullOrEmpty + $r.IsCachedFallback | Should -BeFalse + $script:staleCount | Should -Be 0 + } + It 'Clear-SlotRateLimitBackoff drops the stamp but keeps cached Data' { $Script:SlotUsageCache[$script:boSlot] = @{ Data = 'D'; Timestamp = [DateTime]::UtcNow; RateLimitedUntil = [DateTime]::UtcNow.AddSeconds(120) @@ -2840,6 +2972,7 @@ Describe 'switch_claude_account' { @{ Status = 'rate-limited'; Expected = 'rate-limited' } @{ Status = 'warming-up'; Expected = 'warming up' } @{ Status = 'priming'; Expected = 'priming' } + @{ Status = 'skipped'; Expected = 'skipped' } ) { Get-UsageStatusLabel -Row (New-StatusRow -Status $Status) | Should -Be $Expected } @@ -2907,6 +3040,19 @@ Describe 'switch_claude_account' { $cells.Five | Should -Match '12' $cells.Seven | Should -Match '—' } + + # Every row Get-UsageSnapshot builds carries an Email property, even + # when its value is null. A row assembled anywhere else may not, and + # reading a missing property would hand Format-AccountCell whatever + # PowerShell returns for one rather than the absence of an address. + It 'treats a row with no Email property as having no address' { + $cells = ConvertTo-UsageTableRow -Row ([pscustomobject]@{ + Name = 'a'; IsActive = $false; Status = 'ok'; Data = $null + }) + + # Same cell an explicit $null Email produces: the em-dash. + $cells.Account | Should -Be '—' + } } Context 'Measure-UsageTableColumns' { @@ -3654,15 +3800,23 @@ Describe 'switch_claude_account' { ([regex]::Matches($out, '\bpriming\b')).Count | Should -Be 1 } - It 'Get-StatusColor maps "warming up" to Yellow' { - Get-StatusColor -Label 'warming up' -IsActive $false | Should -Be 'Yellow' - Get-StatusColor -Label 'warming up' -IsActive $true | Should -Be 'Yellow' + It 'Get-StatusColor maps "warming up" to Warning' { + Get-StatusColor -Label 'warming up' -IsActive $false | Should -Be 'Warning' + Get-StatusColor -Label 'warming up' -IsActive $true | Should -Be 'Warning' + } + + It 'Get-StatusColor maps "priming" to Warning' { + # 'priming' is transient like 'warming up' -> Warning. + Get-StatusColor -Label 'priming' -IsActive $false | Should -Be 'Warning' + Get-StatusColor -Label 'priming' -IsActive $true | Should -Be 'Warning' } - It 'Get-StatusColor maps "priming" to Yellow' { - # 'priming' is transient like 'warming up' -> Yellow. - Get-StatusColor -Label 'priming' -IsActive $false | Should -Be 'Yellow' - Get-StatusColor -Label 'priming' -IsActive $true | Should -Be 'Yellow' + It 'Get-StatusColor maps "skipped" to Muted, not to the transients'' Warning' { + # The other two warm-pass labels are in flight and want the eye; + # 'skipped' is terminal and wants none, because the abort advisory + # beside the table is what the user has to read. + Get-StatusColor -Label 'skipped' -IsActive $false | Should -Be 'Muted' + Get-StatusColor -Label 'skipped' -IsActive $true | Should -Be 'Muted' } } @@ -3915,17 +4069,48 @@ Describe 'switch_claude_account' { Get-SlotAuthVerdict -SlotPath $slotPath | Should -BeNullOrEmpty } - It 'activator no-oauth: row ends Status="no-oauth", no mirror or usage read' { + It 'activator no-oauth: row ends Status="no-oauth", no usage read' { New-WarmupSlot -Name 'apikey' | Out-Null Mock Invoke-SlotActivator -MockWith { [pscustomobject]@{ Status = 'no-oauth' } } $snap = Invoke-WarmAllSlots -Name '' -Repaint { } (Get-RowStatus $snap 'apikey') | Should -Be 'no-oauth' - Should -Invoke Invoke-Reconcile -Times 0 -Exactly Should -Invoke Invoke-RestMethod -Times 0 -Exactly -ParameterFilter { $Uri -eq $Script:UsageEndpoint } } + # The next iteration's swap overwrites .credentials.json, so anything + # claude left there has one chance to be captured. A failed activation + # is not a quiet one: claude can refresh the grant and only then be + # turned away (hitting the 5h limit is the common case), and skipping + # the mirror there destroyed that refresh. + It 'mirrors after a FAILED activation too, so a refresh claude landed is not lost' { + New-WarmupSlot -Name 'limited3' | Out-Null + Mock Invoke-SlotActivator -MockWith { [pscustomobject]@{ Status = 'rate-limited' } } + + Invoke-WarmAllSlots -Name '' -Repaint { } | Out-Null + + Should -Invoke Invoke-Reconcile -Times 1 -Exactly + } + + # A verdict asserts claude PROVED the grant dead. That holds only if + # claude wrote nothing: a reconcile that saw the bytes move means a + # refresh went through, so the grant is alive and the refusal was about + # something else. Recording one then strands a working slot behind a + # verdict that outlives the run. + It 'does NOT record a verdict when the credentials moved during the activation' { + New-WarmupSlot -Name 'raced' | Out-Null + Mock Invoke-SlotActivator -MockWith { [pscustomobject]@{ Status = 'unauthorized'; Error = 'forbidden' } } + Mock Invoke-Reconcile -MockWith { New-ReconcileResult -Action 'mirror' -Reason 'mirrored' -Slot 'raced' } + + Invoke-WarmAllSlots -Name '' -Repaint { } | Out-Null + + $state = Read-ScaState + if ($state -and $state.auth_verdicts) { + $state.auth_verdicts.ContainsKey('raced') | Should -BeFalse + } + } + It 'Invoke-SlotActivator throws: row ends Status="error" with the exception message' { New-WarmupSlot -Name 'crash' | Out-Null @@ -4070,15 +4255,13 @@ Describe 'switch_claude_account' { throw [System.Exception]::new('synthetic restore failure on a') } } - Mock Write-Color -MockWith { } - - Invoke-WarmAllSlots -Name '' -Repaint { } | Out-Null + $snap = Invoke-WarmAllSlots -Name '' -Repaint { } # Call order: a (round-robin), b (round-robin), c (throws), # a (restore, throws). - $script:swapNames | Should -Be @('a', 'b', 'c', 'a') - Should -Invoke Write-Color -Times 1 -Exactly -ParameterFilter { $Message -match "active on 'b'" } - Should -Invoke Write-Color -Times 0 -Exactly -ParameterFilter { $Message -match "active on 'c'" } + $script:swapNames | Should -Be @('a', 'b', 'c', 'a') + $snap.Advisory | Should -Match "active on 'b'" + $snap.Advisory | Should -Not -Match "active on 'c'" } It 'restore-failure advisory names the last primed slot when every round-robin swap succeeded' { @@ -4101,12 +4284,187 @@ Describe 'switch_claude_account' { throw [System.Exception]::new('synthetic restore failure on a') } } - Mock Write-Color -MockWith { } - - Invoke-WarmAllSlots -Name '' -Repaint { } | Out-Null + $snap = Invoke-WarmAllSlots -Name '' -Repaint { } $script:swapNames | Should -Be @('a', 'b', 'c', 'a') - Should -Invoke Write-Color -Times 1 -Exactly -ParameterFilter { $Message -match "active on 'c'" } + $snap.Advisory | Should -Match "active on 'c'" + } + + # The three ways the mirror that the round-robin depends on can fail to + # happen. Each ends with a slot holding a refresh token the server has + # already rotated unless the pass stops, which is the one loss here no + # later pass repairs. See Invoke-Reconcile's `Captured`. + + It 'mirrors the slot even when the activator throws after claude -p ran' { + # The activator can throw AFTER `claude -p` has run and refreshed + # (reading its output files, reaching for its exit code). The + # reconcile has to run anyway, or the next swap discards that + # refresh; a sequential call would have been skipped by the catch. + New-WarmupSlot -Name 'a' | Out-Null + + Mock Invoke-SlotActivator -MockWith { + throw [System.Exception]::new('synthetic post-spawn activator failure') + } + + $snap = Invoke-WarmAllSlots -Name '' -Repaint { } + + Should -Invoke Invoke-Reconcile -Times 1 -Exactly + (Get-RowStatus $snap 'a') | Should -Be 'error' + ($snap.Results | Where-Object Name -eq 'a').Error | Should -Match 'synthetic post-spawn' + # The mirror vouched for the bytes, so the pass is not an abort. + $snap.Advisory | Should -BeNullOrEmpty + } + + It 'stops the pass and skips the restore when the mirror reports Captured = $false' { + New-WarmupSlot -Name 'a' | Out-Null + New-WarmupSlot -Name 'b' | Out-Null + New-WarmupSlot -Name 'c' | Out-Null + $statePath = Join-Path $script:CredDirPath '.sca-state.json' + $stateBody = @{ schema = 1; active_slot = 'a'; last_sync_hash = 'deadbeef' } | ConvertTo-Json -Compress + Set-Content -LiteralPath $statePath -Value $stateBody -NoNewline -Encoding utf8NoBOM + + Mock Invoke-Reconcile -MockWith { + New-ReconcileResult -Action 'noop' -Reason 'identity-unresolved' -Slot 'a' -Captured $false + } + + $script:swapNames = @() + Mock Invoke-SlotSwap -MockWith { Param ($Slot); $script:swapNames += $Slot.Name } + + $snap = Invoke-WarmAllSlots -Name '' -Repaint { } + + # One swap only: 'b' is never reached and the restore to 'a' is + # skipped, because both would overwrite the uncaptured bytes. + $script:swapNames | Should -Be @('a') + $snap.Advisory | Should -Match "Stopped at 'a'" + $snap.Advisory | Should -Match 'identity-unresolved' + $snap.Advisory | Should -Match "sca save a" + + # Both unreached rows are finalized rather than left at their + # seeded 'warming-up', which the table renders as in flight and + # Invoke-KeepWarmStep charges as a failed warm. + (Get-RowStatus $snap 'b') | Should -Be 'skipped' + (Get-RowStatus $snap 'c') | Should -Be 'skipped' + } + + It 'leaves the aborting row its own outcome when no row follows it' { + # The boundary the index loop exists for: ($i + 1)..$last counts + # DOWN once $i reaches $last, so a range would stamp 'skipped' over + # the status the aborting row just earned. + New-WarmupSlot -Name 'a' | Out-Null + + Mock Invoke-Reconcile -MockWith { + New-ReconcileResult -Action 'noop' -Reason 'identity-unresolved' -Slot 'a' -Captured $false + } + Mock Invoke-SlotSwap -MockWith { } + Mock Invoke-SlotActivator -MockWith { [pscustomobject]@{ Status = 'no-oauth'; Error = 'synthetic' } } + + $snap = Invoke-WarmAllSlots -Name '' -Repaint { } + + $snap.Advisory | Should -Match "Stopped at 'a'" + (Get-RowStatus $snap 'a') | Should -Be 'no-oauth' + } + + It 'still skips the restore when the repaint throws past the abort' { + # The repaint is the one statement in the loop body outside a catch, + # and the watch startup pass hands it a real renderer that writes to + # the console. A throw there unwinds to the finally, which restores + # unless $uncaptured is already set -- and that restore is one more + # overwrite of the bytes nothing has captured. Decide first, repaint + # second. + New-WarmupSlot -Name 'a' | Out-Null + New-WarmupSlot -Name 'b' | Out-Null + $statePath = Join-Path $script:CredDirPath '.sca-state.json' + $stateBody = @{ schema = 1; active_slot = 'a'; last_sync_hash = 'deadbeef' } | ConvertTo-Json -Compress + Set-Content -LiteralPath $statePath -Value $stateBody -NoNewline -Encoding utf8NoBOM + + Mock Invoke-Reconcile -MockWith { + New-ReconcileResult -Action 'noop' -Reason 'identity-unresolved' -Slot 'a' -Captured $false + } + + $script:swapNames = @() + Mock Invoke-SlotSwap -MockWith { Param ($Slot); $script:swapNames += $Slot.Name } + + # Throws on the repaint that carries the abort advisory, which is + # the one this fix moved the decision in front of. The trigger is + # the advisory itself rather than a call count, so the test also + # fails if the decision moves back behind the repaint: the advisory + # would not be there yet, nothing would throw, and Should -Throw + # would catch it. + { Invoke-WarmAllSlots -Name '' -Repaint { Param ($snap) if ($snap.Advisory) { throw 'synthetic renderer failure' } } } | + Should -Throw '*synthetic renderer failure*' + + # The swap onto 'a' and nothing else: no restore ran behind the + # exception. + $script:swapNames | Should -Be @('a') + } + + It 'stops the pass when the mirror itself throws' { + # Invoke-Reconcile's mirror branch writes through + # Set-CredentialFileAtomic, which throws. That proves nothing about + # the bytes either way, so it is as unsafe to write over as an + # explicit Captured = $false. + New-WarmupSlot -Name 'a' | Out-Null + New-WarmupSlot -Name 'b' | Out-Null + $statePath = Join-Path $script:CredDirPath '.sca-state.json' + $stateBody = @{ schema = 1; active_slot = 'a'; last_sync_hash = 'deadbeef' } | ConvertTo-Json -Compress + Set-Content -LiteralPath $statePath -Value $stateBody -NoNewline -Encoding utf8NoBOM + + Mock Invoke-Reconcile -MockWith { + throw [System.Exception]::new('synthetic atomic write failure') + } + + $script:swapNames = @() + Mock Invoke-SlotSwap -MockWith { Param ($Slot); $script:swapNames += $Slot.Name } + + $snap = Invoke-WarmAllSlots -Name '' -Repaint { } + + $script:swapNames | Should -Be @('a') + $snap.Advisory | Should -Match "Stopped at 'a'" + $snap.Advisory | Should -Match 'synthetic atomic write failure' + } + + It 'treats a reconcile that returned nothing as proof of nothing' { + # `Mock Invoke-Reconcile { }` returns $null, which Common.ps1 warns + # reads as Captured = $false to every caller. Production always + # returns an object, so this guards the harness shape rather than a + # reachable path: a stub must not be able to wave the pass through. + New-WarmupSlot -Name 'a' | Out-Null + New-WarmupSlot -Name 'b' | Out-Null + + Mock Invoke-Reconcile -MockWith { } + + $script:swapNames = @() + Mock Invoke-SlotSwap -MockWith { Param ($Slot); $script:swapNames += $Slot.Name } + + $snap = Invoke-WarmAllSlots -Name '' -Repaint { } + + $script:swapNames | Should -Be @('a') + $snap.Advisory | Should -Match 'the reconcile returned nothing' + } + + It 'a swap failure fails its own slot only, because an atomic rename leaves the file captured' { + # The counterpart to the three aborts above: Invoke-SlotSwap writes + # through an atomic rename, so a throw leaves .credentials.json + # exactly as the previous slot's mirror captured it. Nothing is at + # risk, so the pass must NOT stop. + New-WarmupSlot -Name 'a' | Out-Null + New-WarmupSlot -Name 'b' | Out-Null + + $script:swapNames = @() + Mock Invoke-SlotSwap -MockWith { + Param ($Slot) + $script:swapNames += $Slot.Name + if ($Slot.Name -eq 'a') { throw [System.Exception]::new('synthetic swap failure on a') } + } + + $snap = Invoke-WarmAllSlots -Name '' -Repaint { } + + $script:swapNames | Should -Be @('a', 'b') + (Get-RowStatus $snap 'a') | Should -Be 'error' + (Get-RowStatus $snap 'b') | Should -Be 'ok' + $snap.Advisory | Should -BeNullOrEmpty + # The failed swap never activated, so it must not have mirrored. + Should -Invoke Invoke-Reconcile -Times 1 -Exactly } } @@ -4359,14 +4717,19 @@ Describe 'switch_claude_account' { Should -Invoke Invoke-WarmAllSlots -Times 1 -Exactly } - It 'refuses (without warming) when Claude Code is running' { + # The round-robin no longer refuses a live client. Claude Code + # serializes refreshes across its own processes, so the `claude -p` a + # warm pass spawns cannot race the live session's grant; what is left + # is a prompt sent mid-pass billing the mounted slot. See + # Test-ClaudeRunning. + It 'warms even when Claude Code is running' { Mock Test-ClaudeRunning { $true } $snap = New-KwSnapshot @( (New-KwRow -Name 'a' -FiveResetsAt $null) ) $out = Invoke-KeepWarmStep -Snapshot $snap -WarmupTimes @{} -Threshold 95 -CurrentLatch 'x' - $out | Should -Be '[Warmup] Re-warm refused! Claude Code is running.' - Should -Invoke Invoke-WarmAllSlots -Times 0 -Exactly + $out | Should -Match 'Re-warmed' + Should -Invoke Invoke-WarmAllSlots -Times 1 -Exactly } # The round-robin overwrites .credentials.json once per slot, and this @@ -4490,6 +4853,38 @@ Describe 'switch_claude_account' { $fails['b'] | Should -Be 1 } + # The counterpart to the throw above. A throw says nothing about + # individual slots, so every one of them counts; an abort does, and + # the slots behind it were never tried. + It 'charges nothing to the slots an aborted pass never reached' { + Mock Invoke-WarmAllSlots { + [pscustomobject]@{ + Results = @( + [pscustomobject]@{ Name = 'a'; Status = 'error' }, + [pscustomobject]@{ Name = 'b'; Status = 'skipped' } + ) + Advisory = "[Warmup] Stopped at 'a': nothing captured the credentials Claude Code left active." + } + } + $times = @{}; $fails = @{} + $snap = New-KwSnapshot @( + (New-KwRow -Name 'a' -FiveResetsAt $null), + (New-KwRow -Name 'b' -FiveResetsAt $null) + ) + + Invoke-KeepWarmStep -Snapshot $snap -WarmupTimes $times -Threshold 95 ` + -CooldownMin 5 -WarmupFailures $fails -CurrentLatch 'x' | Out-Null + + # 'a' was tried and did not reach 'ok', so it earns its failure. + $fails['a'] | Should -Be 1 + $times.ContainsKey('a') | Should -BeTrue + + # 'b' was not, so neither the cooldown stamp nor the doubling that + # a repeated abort would compound may touch it. + $fails.ContainsKey('b') | Should -BeFalse + $times.ContainsKey('b') | Should -BeFalse + } + It 'omitting -WarmupFailures keeps the flat-cooldown behaviour' { # Backward compatibility for one-shot callers and existing tests. $times = @{ 'a' = [DateTime]::Now.AddMinutes(-6) } @@ -4500,6 +4895,41 @@ Describe 'switch_claude_account' { Should -Invoke Invoke-WarmAllSlots -Times 1 -Exactly } + + # The watch suppresses Invoke-WarmAllSlots' information stream, so the + # footer latch is the only channel these two facts have. + + It 'latches the live-client notice alongside the re-warm line' { + # `sca warmup` pauses to say this; a watch cannot, and its round- + # robin repeats for the life of the session, so the latch carries + # it. Re-tested per re-warm because a client opened mid-watch is + # dragged across every account by the very next pass. + Mock Test-ClaudeRunning { $true } + $snap = New-KwSnapshot @( (New-KwRow -Name 'a' -FiveResetsAt $null) ) + + $out = Invoke-KeepWarmStep -Snapshot $snap -WarmupTimes @{} -Threshold 95 -CurrentLatch 'x' + + $lines = $out -split "`n" + $lines[0] | Should -Match 'Claude Code is running' + $lines[0] | Should -Match 'bills whichever slot is mounted' + # The slot roll-call survives the prepend. + $lines[1] | Should -Match "^\[Warmup\] Re-warmed 'a' at" + } + + It 'latches the pass advisory over the re-warm line' { + Mock Invoke-WarmAllSlots { + [pscustomobject]@{ + Results = @([pscustomobject]@{ Name = 'a'; Status = 'error' }) + Advisory = "[Warmup] Stopped at 'a': nothing captured the credentials Claude Code left active." + } + } + $snap = New-KwSnapshot @( (New-KwRow -Name 'a' -FiveResetsAt $null) ) + + $out = Invoke-KeepWarmStep -Snapshot $snap -WarmupTimes @{} -Threshold 95 -CurrentLatch 'x' + + $out | Should -Match "Stopped at 'a'" + $out | Should -Not -Match 'Re-warmed' + } } Context 'Get-WarmupCooldownMinutes' { diff --git a/tests/Invoke-WarmupAction.Tests.ps1 b/tests/Invoke-WarmupAction.Tests.ps1 index 4b4a48e..267dffe 100644 --- a/tests/Invoke-WarmupAction.Tests.ps1 +++ b/tests/Invoke-WarmupAction.Tests.ps1 @@ -32,30 +32,129 @@ Describe 'switch_claude_account' { [pscustomobject]@{ Name = 'claude'; Source = 'claude'; CommandType = 'Application' } } - # Stub the orchestration's side effects so no real claude spawns and - # no real HTTP fires; each slot resolves to a healthy 'ok' row. - Mock Invoke-SlotSwap -MockWith { } - Mock Invoke-Reconcile -MockWith { New-ReconcileResult } - Mock Invoke-SlotActivator -MockWith { [pscustomobject]@{ Status = 'ok' } } - Mock Get-SlotUsage -MockWith { - [pscustomobject]@{ - Status = 'ok' - Data = [pscustomobject]@{ - five_hour = [pscustomobject]@{ utilization = 3.0; resets_at = $null } - seven_day = [pscustomobject]@{ utilization = 9.0; resets_at = $null } + } + + Context 'Invoke-WarmupAction' { + BeforeEach { + # Stub the orchestration's side effects so no real claude spawns and + # no real HTTP fires; each slot resolves to a healthy 'ok' row. + # Scoped to this context rather than the file, because the + # activator-internals context below needs the real + # Invoke-SlotActivator and a mock cannot be lifted once set. + Mock Invoke-SlotSwap -MockWith { } + Mock Invoke-Reconcile -MockWith { New-ReconcileResult } + Mock Invoke-SlotActivator -MockWith { [pscustomobject]@{ Status = 'ok' } } + Mock Get-SlotUsage -MockWith { + [pscustomobject]@{ + Status = 'ok' + Data = [pscustomobject]@{ + five_hour = [pscustomobject]@{ utilization = 3.0; resets_at = $null } + seven_day = [pscustomobject]@{ utilization = 9.0; resets_at = $null } + } + Error = $null + IsCachedFallback = $false } - Error = $null - IsCachedFallback = $false } } - } - Context 'Invoke-WarmupAction' { - It 'refuses when Claude Code is running' { + # No longer a refusal: claude serializes refreshes across its own + # processes, so the pass cannot cost a credential. It names the one cost + # that remains, a prompt sent mid-pass billing the mounted slot. + It 'warns but proceeds when Claude Code is running' { + Mock Test-ClaudeRunning -MockWith { $true } + New-SlotPair -CredDir $script:CredDirPath -Name 'a' -Email 'a@test.local' -Content '{}' | Out-Null + + $out = Invoke-WarmupAction -Name '' 6>&1 | Out-String + + $out | Should -Match 'Claude Code is running' + $out | Should -Match 'bills whichever slot is mounted' + Should -Invoke Invoke-SlotActivator -Times 1 -Exactly + } + + # The warning alone is not a decision: the first billable `claude -p` + # follows it by milliseconds, so a user reads it with the round-robin + # already under way. The pause is what makes the Ctrl-C it implies + # reachable. Common.ps1 zeroes the constant for the rest of the suite. + It 'pauses before the first activation when Claude Code is running' { + Mock Test-ClaudeRunning -MockWith { $true } + Mock Start-Sleep -MockWith { } + $Script:WarmupLiveClientPauseSec = 5 + New-SlotPair -CredDir $script:CredDirPath -Name 'a' -Email 'a@test.local' -Content '{}' | Out-Null + + $out = Invoke-WarmupAction -Name '' 6>&1 | Out-String + + $out | Should -Match 'Ctrl-C to abort' + Should -Invoke Start-Sleep -Times 1 -Exactly -ParameterFilter { $Seconds -eq 5 } + } + + It 'does not pause when no Claude Code is running' { + Mock Start-Sleep -MockWith { } + $Script:WarmupLiveClientPauseSec = 5 + New-SlotPair -CredDir $script:CredDirPath -Name 'a' -Email 'a@test.local' -Content '{}' | Out-Null + + Invoke-WarmupAction -Name '' 6>$null + + Should -Invoke Start-Sleep -Times 0 -Exactly + } + + # The notice describes what the round-robin will cost and the pause + # offers five seconds to call it off. Neither has anything to say when + # the pass is about to report that no slot matched: there is no cost + # coming and nothing to abort. + It 'says nothing about a live client when ' -ForEach @( + @{ Case = 'no slots are saved'; Slot = $null; Filter = '' } + @{ Case = '-Name matches nothing'; Slot = 'a'; Filter = 'no-such-slot' } + ) { Mock Test-ClaudeRunning -MockWith { $true } + Mock Start-Sleep -MockWith { } + $Script:WarmupLiveClientPauseSec = 5 + if ($Slot) { + New-SlotPair -CredDir $script:CredDirPath -Name $Slot -Email "$Slot@test.local" -Content '{}' | Out-Null + } + + $out = Invoke-WarmupAction -Name $Filter 6>&1 | Out-String + + $out | Should -Not -Match 'Claude Code is running' + $out | Should -Not -Match 'Ctrl-C to abort' + $out | Should -Match 'No slots' + Should -Invoke Start-Sleep -Times 0 -Exactly + } + + # Get-SafeName advises when it changes the name. Resolving it once and + # reusing the result is what keeps that advisory from being printed by + # the preflight, by the pass, and by the no-slots message in turn. + It 'advises about a sanitized name exactly once' { + New-SlotPair -CredDir $script:CredDirPath -Name 'a' -Email 'a@test.local' -Content '{}' | Out-Null + + $out = Invoke-WarmupAction -Name 'my missing' 6>&1 | Out-String + + ([regex]::Matches($out, "Sanitized to: 'my_missing'")).Count | Should -Be 1 + } + + # The pass stops rather than overwrite bytes nothing captured, which + # leaves the user on a slot they did not choose. That is the one thing + # they have to read, so it precedes the table. + It 'prints the pass advisory ahead of the usage table' { New-SlotPair -CredDir $script:CredDirPath -Name 'a' -Email 'a@test.local' -Content '{}' | Out-Null + Mock Invoke-WarmAllSlots -MockWith { + [pscustomobject]@{ + Results = @( + [pscustomobject]@{ + Name = 'a'; Email = 'a@test.local'; IsActive = $true + Status = 'ok'; Data = $null; Error = $null + IsCachedFallback = $false; HttpStatus = $null; FallbackReason = $null + } + ) + NoSlots = $false + HasRateLimited = $false + Advisory = "[Warmup] Stopped at 'a': nothing captured the credentials Claude Code left active." + } + } - { Invoke-WarmupAction -Name '' 6>$null } | Should -Throw -ExpectedMessage '*Claude Code is running*' + $out = Invoke-WarmupAction -Name '' 6>&1 | Out-String + + $out | Should -Match "Stopped at 'a'" + $out.IndexOf('Stopped at') | Should -BeLessThan $out.IndexOf('Plan usage') } It 'refuses when the claude CLI is not on PATH' { @@ -106,6 +205,86 @@ Describe 'switch_claude_account' { Should -Invoke Invoke-SlotActivator -Times 1 -Exactly } + + # "No slots saved" and "no slot by that name" are different problems + # with different fixes, and the advisory is the only place the + # difference is visible. The name is echoed through Get-SafeName so + # what is quoted back is the name actually looked for. + It 'names the filter when -Name matches nothing' { + New-SlotPair -CredDir $script:CredDirPath -Name 'a' -Email 'a@test.local' -Content '{}' | Out-Null + + $out = Invoke-WarmupAction -Name 'my missing' 6>&1 | Out-String + + $out | Should -Match "No slots matching 'my_missing' to activate" + Should -Invoke Invoke-SlotActivator -Times 0 -Exactly + } + + # The pass spaces successive activations so a multi-slot warm does not + # arrive at the endpoint as a burst. Spacing is collapsed to 0 for the + # suite, so the only way to see the pacing is to put it back. + It 'pauses between slots but not after the last one' { + New-SlotPair -CredDir $script:CredDirPath -Name 'a' -Email 'a@test.local' -Content '{}' | Out-Null + New-SlotPair -CredDir $script:CredDirPath -Name 'b' -Email 'b@test.local' -Content '{}' | Out-Null + New-SlotPair -CredDir $script:CredDirPath -Name 'c' -Email 'c@test.local' -Content '{}' | Out-Null + + $Script:WarmupSpacingMs = 1 + Mock Start-Sleep -MockWith { } + + Invoke-WarmupAction -Name '' 6>$null + + # Three slots, two gaps. + Should -Invoke Start-Sleep -Times 2 -Exactly + } + } + + Context 'Invoke-SlotActivator / Invoke-ClaudeActivatorProcess internals' { + # No Invoke-SlotActivator stub here: these cases are about that + # function's own classification and the child-process wrapper beneath + # it. Nothing spawns a real claude, because the wrapper is either + # mocked or driven through a mocked Start-Process. + + # Kill can lose the race with a process that exits just after + # WaitForExit gave up. The answer is still "timed out": the caller + # needs a verdict about the activation, not about the cleanup. + It 'still reports a timeout when killing the hung process fails' { + Mock Start-Process -MockWith { + $p = [pscustomobject]@{ ExitCode = 0 } + $p | Add-Member -MemberType ScriptMethod -Name WaitForExit -Value { Param ($ms) return $false } + $p | Add-Member -MemberType ScriptMethod -Name Kill -Value { + Param ($entireTree) + throw [System.InvalidOperationException]::new('process has already exited') + } + return $p + } + + $r = Invoke-ClaudeActivatorProcess -ClaudeArgs @('-p', 'Hi') -TimeoutSec 1 + + $r.TimedOut | Should -BeTrue + $r.ExitCode | Should -BeNullOrEmpty + $r.Stdout | Should -Be '' + } + + # claude's JSON envelope does not always carry a sentence. subtype is + # the last field with any signal in it, and without this arm such a + # failure rendered as the bare exit code. + It 'falls back to the JSON subtype when there is no result or error text' { + $slot = New-SlotPair -CredDir $script:CredDirPath -Name 'a' -Email 'a@test.local' ` + -Content '{"claudeAiOauth":{"accessToken":"AT","refreshToken":"RT","expiresAt":9999999999999}}' + + Mock Invoke-ClaudeActivatorProcess -MockWith { + [pscustomobject]@{ + TimedOut = $false + ExitCode = 1 + Stdout = '{"type":"result","is_error":true,"subtype":"error_during_execution"}' + Stderr = '' + } + } + + $r = Invoke-SlotActivator -SlotPath $slot 6>$null + + $r.Status | Should -Not -Be 'ok' + $r.Error | Should -Match 'error_during_execution' + } } AfterAll { diff --git a/tests/State-File.Tests.ps1 b/tests/State-File.Tests.ps1 index 4bd4510..6d5c421 100644 --- a/tests/State-File.Tests.ps1 +++ b/tests/State-File.Tests.ps1 @@ -564,6 +564,21 @@ Describe 'switch_claude_account' { Read-ScaState | Should -BeNullOrEmpty } + + # Persisting the migration is an optimisation: it makes the next read + # O(1). The read itself already has the answer, so a failed write must + # cost the caller nothing, and the migration simply runs again next + # time. + It 'reports the migrated state even when persisting it fails' { + Set-Content -LiteralPath (Join-Path $script:SandboxCredDir '.credentials.json') -Value 'PAYLOAD' -NoNewline + Set-Content -LiteralPath (Join-Path $script:SandboxCredDir '.credentials.work(alice@example.com).json') -Value 'PAYLOAD' -NoNewline + Mock Write-ScaState -MockWith { throw [System.IO.IOException]::new('read-only volume') } + + $r = Read-ScaState + + $r.active_slot | Should -Be 'work' + Test-Path -LiteralPath $StateFile | Should -BeFalse + } } Context 'Update-ScaState' { @@ -618,6 +633,22 @@ Describe 'switch_claude_account' { $r.last_sync_hash | Should -Be 'h1' } + # Read-ScaState always supplies auth_verdicts, so the only way in is a + # caller that built the state object itself. Without the block the next + # assignment to it would fail on a property that is not there, which + # would take down whichever action happened to record a verdict first. + It 'adds the auth_verdicts block to a state object that predates it' { + Mock Read-ScaState -MockWith { + [pscustomobject]@{ schema = 1; active_slot = 'work'; last_sync_hash = 'h' } + } + + $r = Update-ScaState -LastSyncHash 'h2' + + $r.PSObject.Properties['auth_verdicts'] | Should -Not -BeNullOrEmpty + $r.auth_verdicts | Should -BeOfType [hashtable] + $r.auth_verdicts.Count | Should -Be 0 + $r.last_sync_hash | Should -Be 'h2' + } } Context 'Legacy state-file tolerance (v2.3.0 - v2.4.0-draft compatibility)' { @@ -760,6 +791,45 @@ Describe 'switch_claude_account' { $r.auth_verdicts.ContainsKey('kept') | Should -BeTrue } + # error is optional in the stored shape: claude -p can prove a grant is + # dead without producing a sentence about it. The reader has to keep + # such an entry, because status and cred_hash are what make it usable. + It 'keeps a verdict that carries no error text' { + $stateJson = '{"schema":1,"active_slot":"work","last_sync_hash":"h","auth_verdicts":{' + + '"quiet":{"status":"expired","cred_hash":"abc"}}}' + Set-Content -LiteralPath $StateFile -Value $stateJson -NoNewline -Encoding utf8NoBOM + + $r = Read-ScaState + $r.auth_verdicts.ContainsKey('quiet') | Should -BeTrue + $r.auth_verdicts['quiet'].status | Should -Be 'expired' + $r.auth_verdicts['quiet'].error | Should -BeNullOrEmpty + } + + # A verdict is a label. Losing one costs a row the honest word for why + # it failed; failing the action that was recording it costs the user + # the thing they actually asked for. + It 'does not throw when the verdict cannot be recorded' { + Mock Update-ScaState -MockWith { throw [System.IO.IOException]::new('state file locked') } + + { Set-SlotAuthVerdict -SlotName 'work' -SlotPath $script:vSlot -Status 'expired' -ErrorMessage 'boom' } | + Should -Not -Throw + } + + It 'does not throw when the verdict cannot be cleared' { + Set-SlotAuthVerdict -SlotName 'work' -SlotPath $script:vSlot -Status 'expired' -ErrorMessage 'boom' + Mock Update-ScaState -MockWith { throw [System.IO.IOException]::new('state file locked') } + + { Clear-SlotAuthVerdict -SlotName 'work' } | Should -Not -Throw + } + + # Verdicts are keyed by slot name, which only a parseable slot filename + # yields. Anything else has no key to look up. + It 'returns null for a path that is not a slot filename' { + Set-SlotAuthVerdict -SlotName 'work' -SlotPath $script:vSlot -Status 'expired' -ErrorMessage 'boom' + + Get-SlotAuthVerdict -SlotPath (Join-Path $script:vCredDir 'notes.txt') | Should -BeNullOrEmpty + } + It 'tolerates a state file with no auth_verdicts block at all' { Set-Content -LiteralPath $StateFile -Value '{"schema":1,"active_slot":"w","last_sync_hash":"h"}' -NoNewline -Encoding utf8NoBOM diff --git a/tests/Test-ClaudeRunning.Tests.ps1 b/tests/Test-ClaudeRunning.Tests.ps1 new file mode 100644 index 0000000..df87dcc --- /dev/null +++ b/tests/Test-ClaudeRunning.Tests.ps1 @@ -0,0 +1,101 @@ +#Requires -Version 7.4 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +# Pester 5 tests for Test-ClaudeRunning in switch_claude_account.ps1: the +# guard `save` refuses on and `warmup` warns about. +# +# Its own file because tests/Common.ps1 mocks this function for the whole +# suite, and a mock cannot be lifted once set. The BeforeEach below sets +# $script:ScaKeepRealClaudeRunning first, which is the only supported way to +# opt out; see the comment on that mock for why nothing else may. +# +# The pattern match itself lives in Test-ClaudeNodeProcess and is covered in +# Helpers.Tests.ps1 on every platform. What is left here is the part that +# differs by platform: which probes run at all. + +BeforeAll { + $script:OriginalUserProfile = $env:USERPROFILE + $script:OriginalProfile = $global:PROFILE + $script:OriginalHome = $env:HOME + $script:OriginalConfigDir = $env:CLAUDE_CONFIG_DIR +} + +Describe 'switch_claude_account' { + + BeforeEach { + # Must be set BEFORE Common.ps1 is dot-sourced: the mock it installs + # is conditional on this flag. + $script:ScaKeepRealClaudeRunning = $true + . (Join-Path $PSScriptRoot 'Common.ps1') + } + + Context 'Test-ClaudeRunning' { + # The native installer produces a real executable named 'claude', so + # the name probe finds it on every platform and nothing else needs to + # run. + It 'reports true when a process named claude is running' { + Mock Get-Process -ParameterFilter { $Name -eq 'claude' } -MockWith { + [pscustomobject]@{ Name = 'claude'; Id = 4242 } + } + + Test-ClaudeRunning | Should -BeTrue + } + + # Reading .CommandLine off every process costs about 53 s on Windows, + # where the property is backed by a per-process CIM query. A guard that + # runs before every save / switch / rotation cannot spend that, so the + # second probe is skipped there. The catch-all mock below turns a + # regression into a failure rather than a slow suite. + It 'reports false on Windows without enumerating every process' -Skip:(-not $IsWindows) { + Mock Get-Process -MockWith { throw 'the full enumeration must not run on Windows' } + Mock Get-Process -ParameterFilter { $Name -eq 'claude' } -MockWith { } + + Test-ClaudeRunning | Should -BeFalse + + Should -Invoke Get-Process -Times 1 -Exactly -ParameterFilter { $Name -eq 'claude' } + } + + # The npm package is a Node script behind a shim, so its process is + # 'node' and the name probe misses it entirely. Off Windows the + # command-line probe is what catches it. + It 'falls through to the command-line probe off Windows' -Skip:$IsWindows { + Mock Get-Process -ParameterFilter { $Name -eq 'claude' } -MockWith { } + Mock Get-Process -ParameterFilter { -not $Name } -MockWith { + @( + [pscustomobject]@{ Name = 'bash'; CommandLine = '-bash' } + [pscustomobject]@{ Name = 'node'; CommandLine = '/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js' } + ) + } + + Test-ClaudeRunning | Should -BeTrue + } + + It 'reports false off Windows when no command line matches' -Skip:$IsWindows { + Mock Get-Process -ParameterFilter { $Name -eq 'claude' } -MockWith { } + Mock Get-Process -ParameterFilter { -not $Name } -MockWith { + @([pscustomobject]@{ Name = 'node'; CommandLine = '/srv/claude-notes/server.js' }) + } + + Test-ClaudeRunning | Should -BeFalse + } + + # An unreadable /proc entry, or a process that exits mid-scan, must not + # turn a safety guard into a terminating error. "Not detected" is the + # answer the name probe already gave. + It 'treats an enumeration failure as not detected' -Skip:$IsWindows { + Mock Get-Process -ParameterFilter { $Name -eq 'claude' } -MockWith { } + Mock Get-Process -ParameterFilter { -not $Name } -MockWith { + throw [System.InvalidOperationException]::new('process exited mid-scan') + } + + Test-ClaudeRunning | Should -BeFalse + } + } + + AfterAll { + $env:USERPROFILE = $script:OriginalUserProfile + $global:PROFILE = $script:OriginalProfile + $env:HOME = $script:OriginalHome + $env:CLAUDE_CONFIG_DIR = $script:OriginalConfigDir + } +} diff --git a/tools/Render-ReadmeImages.ps1 b/tools/Render-ReadmeImages.ps1 index 1b52f6a..7ba196b 100644 --- a/tools/Render-ReadmeImages.ps1 +++ b/tools/Render-ReadmeImages.ps1 @@ -28,6 +28,14 @@ text as the source of truth and colorising it is simpler than reverse-engineering inputs that round-trip through the real renderer. + The Session bar of 25% does not average the visible Session cells + either, but that one is exactly what the renderer would emit: 'legacy' + sits at the 100% Week cap, so Get-PoolMeanUtilization drops it from the + Session average altogether and the bar is (18+3+9+71)/4 over the four + reachable slots. Do not "correct" it to the 23% a five-row average + gives. The Week bar keeps all five rows, which is why only one of the + two bars changes when a slot hits its weekly cap. + One deliberate divergence from the README's pre-image ASCII: the bar's empty portion is rendered with `▓` (medium shade block, U+2593) rather than spaces. That matches what `Format-AggregateBars` actually emits @@ -52,20 +60,22 @@ case 2:` branch in `freeze/ansi.go`). So we sidestep the hardcoded palette by emitting Campbell hexes directly via truecolor. - Color map (logical name -> Campbell hex -> where it shows): - DarkYellow -> #C19C00 headers, bar percent label - DarkGray -> #767676 footer, Account label - Green -> #16C60C active rows, ok status, green bars - Yellow -> #F9F1A5 yellow bars, near-limit rows - Red -> #E74856 red bars, limited rows - Gray -> #CCCCCC inactive ok rows - - Logical name = the value passed to `Write-Color` in - switch_claude_account.ps1 around line 858. The mapping there from - logical name to `$PSStyle` SGR (DarkYellow -> 33, Green -> 92, ...) - is a runtime artifact of how Windows Terminal renders those SGRs as - Campbell hexes; here we burn the hexes in directly so the SVGs are - independent of any terminal palette. + Color map (role -> Campbell hex -> where it shows): + Heading -> #C19C00 headers, bar percent label + Muted -> #767676 footer, Account label + Success -> #16C60C active rows, ok status, green bars + Warning -> #F9F1A5 yellow bars, near-limit rows + Danger -> #E74856 red bars, limited rows + Neutral -> #CCCCCC inactive ok rows + + Role = the value passed to `Write-Color` in switch_claude_account.ps1; + `Write-Color`'s own docblock owns what each role means. These hexes + are what Windows Terminal renders the DEFAULT theme's SGR codes as + (Heading -> 33, Success -> 92, ...), burned in directly so the SVGs + are independent of any terminal palette. + + This is not a `SCA_THEME` entry and must not drift into one: the SVGs + document the default theme, so they are rendered with SCA_THEME unset. .PARAMETER OutputDir Where to write the rendered SVGs. Default: /docs/images. @@ -140,20 +150,20 @@ New-Item -ItemType Directory -Path $tmpRoot -Force | Out-Null # .DESCRIPTION above for rationale. $ESC = [char]27 $RESET = "$ESC[0m" -$DKYEL = "$ESC[38;2;193;156;0m" # #C19C00 Campbell Yellow (DarkYellow) -$DKGRY = "$ESC[38;2;118;118;118m" # #767676 Campbell Brt Black (DarkGray) -$GREEN = "$ESC[38;2;22;198;12m" # #16C60C Campbell Brt Green (Green) -$YELLO = "$ESC[38;2;249;241;165m" # #F9F1A5 Campbell Brt Yellow (Yellow) -$RED = "$ESC[38;2;231;72;86m" # #E74856 Campbell Brt Red (Red) -$GRAY = "$ESC[38;2;204;204;204m" # #CCCCCC Campbell White (Gray) +$DKYEL = "$ESC[38;2;193;156;0m" # #C19C00 Campbell Yellow (Heading) +$DKGRY = "$ESC[38;2;118;118;118m" # #767676 Campbell Brt Black (Muted) +$GREEN = "$ESC[38;2;22;198;12m" # #16C60C Campbell Brt Green (Success) +$YELLO = "$ESC[38;2;249;241;165m" # #F9F1A5 Campbell Brt Yellow (Warning) +$RED = "$ESC[38;2;231;72;86m" # #E74856 Campbell Brt Red (Danger) +$GRAY = "$ESC[38;2;204;204;204m" # #CCCCCC Campbell White (Neutral) # --- Block 1: usage -Watch (README ~lines 17-33) --------------------------- -# Multi-slot watch frame with 5 rows; bars at 22% (green) / 62% (yellow); -# trailing [Watch] footer in DarkGray. +# Multi-slot watch frame with 5 rows; bars at 25% (green) / 62% (yellow); +# trailing [Watch] footer in Muted. $watchLines = @( "$DKYEL[Usage] Plan usage$RESET", "", - "$GREEN Session [█████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 22%$RESET", + "$GREEN Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25%$RESET", "", "$YELLO Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62%$RESET", "", @@ -198,7 +208,7 @@ $verboseLines = @( # Same five-row watch frame as Block 1, plus the two auto-rotation artifacts: # # 1. Right-aligned header indicator '▶ switching slot at 95%'. Glyph -# in Gray (white-ish, high-contrast lozenge); text in DarkGray +# in Neutral (white-ish, high-contrast lozenge); text in Muted # (matches footer ambient-metadata weight). See Format-UsageTable in # switch_claude_account.ps1 around line 2779-2810 for the runtime's # three-segment Write-Color composition we are imitating here. @@ -228,33 +238,151 @@ $verboseLines = @( # auto-mode-on vs. auto-mode-off with no other deltas. $autoHeaderPad = ' ' * 37 $autoGlyph = "$([char]0x25B6)" -$watchAutoLines = @( - "$DKYEL[Usage] Plan usage$RESET$autoHeaderPad$GRAY$autoGlyph$RESET$DKGRY switching slot at 95%$RESET", - "", - "", - "$GREEN Session [█████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 22%$RESET", - "", - "$YELLO Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62%$RESET", - "", - " Slot Account Session Week Status", - " ----------- --------------------- ------------- ----------- ------", - "$GREEN * work alex@acme.io 18% (2h 11m) 42% (102h) ok$RESET", - "$GRAY personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok$RESET", - "$GRAY dev alex@startup.dev 9% (3h 41m) 34% (118h) ok$RESET", - "$YELLO client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit$RESET", - "$RED legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d$RESET", - "", - "$DKGRY[Monitor] Rotated from `"legacy`" to `"work`" at 14:31:58$RESET", - "$DKGRY[Watch] Last poll at 14:32:07$RESET" + +# Role -> SGR for the palette the four README scenes are drawn in. Campbell is +# Windows Terminal's default, so this is what the `default` theme resolves to +# on a stock Windows install. +$campbellPalette = @{ + Heading = $DKYEL; Warning = $YELLO; Success = $GREEN + Danger = $RED; Muted = $DKGRY; Neutral = $GRAY +} + +# The hero scene as a function of its palette rather than one literal per +# palette. It is rendered once in Campbell for monitor.svg and again for every +# theme in the gallery, and two copies of eighteen hand-aligned columns would +# drift apart on the first edit. +function New-HeroLines { + Param ([Parameter(Mandatory)] [hashtable] $Palette) + + $hd = $Palette.Heading; $wn = $Palette.Warning; $sc = $Palette.Success + $dg = $Palette.Danger; $mt = $Palette.Muted; $nt = $Palette.Neutral + + return @( + "$hd[Usage] Plan usage$RESET$autoHeaderPad$nt$autoGlyph$RESET$mt switching slot at 95%$RESET", + "", + "", + "$sc Session [██████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 25%$RESET", + "", + "$wn Week [███████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 62%$RESET", + "", + " Slot Account Session Week Status", + " ----------- --------------------- ------------- ----------- ------", + "$sc * work alex@acme.io 18% (2h 11m) 42% (102h) ok$RESET", + "$nt personal alex.dev@gmail.com 3% (4h 02m) 7% (146h) ok$RESET", + "$nt dev alex@startup.dev 9% (3h 41m) 34% (118h) ok$RESET", + "$wn client-acme ada.lovelace@arpa.net 71% (1h 04m) 92% (41h) near limit$RESET", + "$dg legacy team@example.com 12% (3h 18m) 100% (12h) limited 7d$RESET", + "", + "$mt[Monitor] Rotated from `"legacy`" to `"work`" at 14:31:58$RESET", + "$mt[Watch] Last poll at 14:32:07$RESET" + ) +} + +$watchAutoLines = New-HeroLines -Palette $campbellPalette + +# --- Block 5: theme gallery (README Theming) -------------------------------- +# Generated from the palette table the tool actually ships, not transcribed +# here, so the gallery cannot drift from the themes on offer. Dot-sourcing is +# inert: the guard at the foot of switch_claude_account.ps1 keeps Invoke-Main +# from running, and its load path only resolves paths and builds tables -- +# nothing reads or writes a credential. +# +# The whole monitor scene is repeated per theme rather than a swatch strip: +# the question a reader brings here is "what will this look like", and the +# answer is the view they will actually sit in front of. +# +# The canvas is freeze's own --background, NOT an SGR painted behind each row. +# Painting per row leaves the window's 30px padding showing the default +# terminal black around the edges, so the panel reads as a themed rectangle +# floating on somebody else's background. Handing the color to --background +# fills the whole window face, and makes the per-row paint and the +# pad-to-width that went with it unnecessary. +# +# `default` uses Campbell. It spells its roles as named ANSI and so owns no +# background, taking whatever the terminal supplies; docs/themes.md says as +# much, because no single image can be honest about a palette-relative theme. +. (Join-Path $repoRoot 'switch_claude_account.ps1') + +function Get-GallerySgr { + Param ([int] $Rgb) + + $r = ($Rgb -shr 16) -band 0xFF + $g = ($Rgb -shr 8) -band 0xFF + $b = $Rgb -band 0xFF + return "$ESC[38;2;$r;$g;${b}m" +} + +$galleryThemes = @( + [pscustomobject]@{ + Name = 'default' + Background = '#0C0C0C' + PanelFg = '' + Palette = $campbellPalette + } ) +foreach ($schemeName in ($Script:Base16Schemes.Keys | Sort-Object)) { + $scheme = $Script:Base16Schemes[$schemeName] + $galleryThemes += [pscustomobject]@{ + Name = $schemeName + Background = ('#{0:X6}' -f $scheme.base00) + PanelFg = (Get-GallerySgr $scheme.base05) + Palette = @{ + Heading = (Get-GallerySgr $scheme.base0D) + Warning = (Get-GallerySgr $scheme.base0A) + Success = (Get-GallerySgr $scheme.base0B) + Danger = (Get-GallerySgr $scheme.base08) + Muted = (Get-GallerySgr $scheme.base03) + # Neutral carries no color of its own in a truecolor theme. Inside + # a themed frame it inherits that theme's Foreground, so base05 is + # what the runtime would actually show here. + Neutral = (Get-GallerySgr $scheme.base05) + } + } +} + +# Give a scene the theme's body-text color. +# +# Only the foreground needs doing here; --background owns the canvas. The +# re-assertion after every ESC[0m is what makes it work: Write-Color's scenes +# end each colored run with a full reset, which would otherwise drop the +# uncolored remainder of a line back to freeze's own #c4c4c4 rather than the +# theme's base05. +function ConvertTo-ThemedPanel { + Param ( + # AllowEmptyString because the scene uses blank lines as spacing, and + # Mandatory alone rejects an array element that is ''. + [Parameter(Mandatory)] [AllowEmptyString()] [string[]] $Lines, + [string] $Fg + ) + + foreach ($line in $Lines) { + if (-not $Fg) { $line; continue } + $Fg + $line.Replace($RESET, $RESET + $Fg) + $RESET + } +} $scenarios = @( - [pscustomobject]@{ Name = 'usage-watch'; Lines = $watchLines }, - [pscustomobject]@{ Name = 'usage-table'; Lines = $tableLines }, - [pscustomobject]@{ Name = 'usage-verbose'; Lines = $verboseLines }, - [pscustomobject]@{ Name = 'monitor'; Lines = $watchAutoLines } + [pscustomobject]@{ Name = 'usage-watch'; Lines = $watchLines }, + [pscustomobject]@{ Name = 'usage-table'; Lines = $tableLines }, + [pscustomobject]@{ Name = 'usage-verbose'; Lines = $verboseLines }, + [pscustomobject]@{ Name = 'monitor'; Lines = $watchAutoLines } ) +# One file per theme, not one tall strip. docs/themes.md gives each theme a +# heading of its own so a reader can link straight to the one they want, and a +# heading needs its own content underneath for that anchor to be worth +# following. The theme name lives in the markdown heading, so the panel no +# longer carries a label of its own. +foreach ($gt in $galleryThemes) { + $scenarios += [pscustomobject]@{ + Name = "theme-$($gt.Name)" + Background = $gt.Background + Lines = ConvertTo-ThemedPanel ` + -Lines (New-HeroLines -Palette $gt.Palette) ` + -Fg $gt.PanelFg + } +} + # --- Render ----------------------------------------------------------------- # freeze flags rationale: # --language ansi : interpret SGR codes in input @@ -290,9 +418,16 @@ $scenarios = @( # --font.size 14 : default; readable in README at GitHub's render width # --line-height 1.4 : avoids cramped vertical spacing # Font defaults to JetBrains Mono and is embedded as a base64 woff2 in the -# SVG, so the rendered output is pixel-identical regardless of the -# viewer's installed fonts. Adds ~300 KB per SVG, acceptable for README -# assets. +# SVG, so the rendered output is pixel-identical regardless of the viewer's +# installed fonts. That costs ~365 KB of every file against ~1 KB of actual +# drawing, and is paid once per image including each theme panel. +# +# Stripping it for a fallback chain was tried and reverted. freeze emits no +# per-glyph positions and no textLength: the advance of every line comes from +# the font, so a substituted face moves the text off the geometry freeze +# computed from JetBrains Mono metrics. The visible symptom is the usage bars, +# whose block glyphs (U+2588 / U+2593) stop filling their cell. Pixel fidelity +# here is load-bearing, not a nicety. $utf8NoBom = New-Object System.Text.UTF8Encoding($false) foreach ($s in $scenarios) { @@ -302,11 +437,15 @@ foreach ($s in $scenarios) { [System.IO.File]::WriteAllText($ansiPath, $body, $utf8NoBom) + # Campbell unless the scene names its own; a theme panel hands its base00 + # here so the color reaches the padding too, not just the text rows. + $background = if ($s.Background) { $s.Background } else { '#0C0C0C' } + Write-Host "Rendering $($s.Name) -> $svgPath" -ForegroundColor Cyan & $freezeExe ` --language ansi ` --window ` - --background '#0C0C0C' ` + --background $background ` --padding 30 ` --margin 0 ` --width 720 `