Skip to content

Add copilot-relay usage: Copilot plan and quota - #156

Merged
D0n9X1n merged 6 commits into
mainfrom
feat/usage-command
Oct 3, 2026
Merged

D0n9X1n merged 6 commits into
mainfrom
feat/usage-command

Conversation

@D0n9X1n

@D0n9X1n D0n9X1n commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds copilot-relay usage [--json] (#154): the Copilot plan, SKU and quota reset date, then one line per quota, from GitHub's copilot_internal/user, requested with the stored GitHub token. No relay needs to run. It exchanges no Copilot token and writes no file, the log included.

  • src/lib/auth.ts: getCopilotUsage sends the request with githubHeaders and the same fetch as the other GitHub calls in that file, so the [Feature]: Send Copilot and GitHub calls through an outbound proxy (upstreamProxy) #153 dispatcher change can move it with them. readStoredGitHubToken reads the token without creating a directory or file and without migrating a legacy token.
  • src/lib/usage.ts: checks every field on its own (a missing or mistyped field counts as not reported), keeps only the plan and quota fields, makes each string terminal-safe, renders the report, and turns each failure into one line. It refuses an answer whose plan, SKU, reset date or quota ids would show the stored token: written exactly, with other characters inside it, or spread over those strings.
  • src/usage.ts, registered in src/main.ts: prints to stdout and stderr and sets process.exitCode = 1 instead of throwing, because citty prints a thrown error with consola.error, which also writes the log file.
  • src/lib/config.ts: exports vscodeVersion, so the request sends the editor version without readAppConfig, which can write config.yaml.

Text output, with placeholder values:

Plan         <copilot_plan>
SKU          <access_type_sku>
Quota reset  <quota_reset_date>

chat                  unlimited; overage not permitted, overage count <overage_count>
completions           unlimited; overage not permitted, overage count <overage_count>
premium_interactions  <remaining> of <entitlement> remaining (<percent_remaining>%); overage permitted, overage count <overage_count>

A quota with credits_used gets ; credits used <n> at the end. Values print as GitHub sent them; nothing is rounded or computed. A missing field prints ?; a missing plan, SKU, reset date or quota prints not reported. --json prints a curated object with the same fields, never the raw response. copilot_plan, access_type_sku, quota_reset_date and each known quota id are always present; a value, snapshot or field that is absent or mistyped is null; a snapshot object always has all eight keys.

Closes #154

Scope

A CLI command only: no HTTP route, no config key, and no change to the relay's request path.

Validation

  • npm run typecheck
  • npm run test:unit: 986 tests, 956 pass, 30 skipped (none new), 0 fail
  • npm run test:integration: 402 tests, 402 pass, 0 fail
  • npm run build

Run locally on Windows with Node 24.19.0. New tests: tests/unit/usage.test.ts (43) and tests/integration/usage-command.test.ts (9). Both mock fetch; nothing calls GitHub. The integration test runs the CLI in a child process against a temporary home and checks that nothing in it is created or changed.

Notes

  • Token: read from ~/.copilot-relay/github_token and sent only in the authorization header. GitHub's answer and a network error's reason are checked for it before anything is printed: written exactly, with other characters between its letters and digits, or, in an answer, spread over the plan, SKU, reset date and quota ids. An exact copy in a reason prints as [redacted]; a reason that still spells the token prints Could not reach GitHub.; an answer that would show it is refused with one fixed line, and none of it is printed. The two looser forms are searched for only when the token has at least 16 letters and digits. A token written any other way, such as in another case or encoding, is not found.
  • The request has a 30-second timeout with its own message.
  • Logging: none; the command never calls the logger.
  • Config: no change.

Checklist

  • Public API remains Claude Code-only unless intentionally changed.
  • Config changes are reflected in config.default.yaml, README, and wiki/ (EN and ZH). No config change; the command is documented in the README and in both wiki languages.
  • Logs do not expose tokens.
  • Integration tests mock upstream Copilot; they do not call real Copilot services.

After merge

  • Confirm publish-wiki.yml succeeded on the merge SHA, then click through the new sections on the live EN and 中文 tabs.
  • Remove inactive local/remote feature branches and temporary worktrees, prune stale refs, and report preserved work; follow the Development Wiki cleanup rules.

🤖 Generated with Claude Code

copilot-relay usage [--json] prints the Copilot plan, SKU and quota
reset date, then one line per quota, from GitHub's copilot_internal/user.
It requests it with the stored GitHub token, so no relay needs to run.
It exchanges no Copilot token and writes no file, the log included.

- src/lib/auth.ts: getCopilotUsage sends the request with githubHeaders
  and the same fetch as the other GitHub calls in that file, so the
  shared proxy dispatcher for #153 can move it with them.
  readStoredGitHubToken reads the token without creating a directory or
  file and without migrating a legacy token.
- src/lib/usage.ts: checks every field on its own, keeps only the plan
  and quota fields, makes each string terminal-safe, renders the report,
  and turns each failure into one line that never holds the token or
  the request headers.
- src/usage.ts, registered in src/main.ts: prints to stdout and stderr
  and sets process.exitCode = 1 instead of throwing, because citty
  prints a thrown error with consola.error, which also writes the log.
- src/lib/config.ts: exports vscodeVersion, so the request sends the
  editor version without readAppConfig, which can write config.yaml.

The report shows GitHub's values as they are and computes nothing new.
The --json form prints a curated object, never the raw response, which
carries identifying fields such as the login, organization lists and
tracking ids. Tests mock fetch and never call GitHub. The README, both
Logging-Troubleshooting pages and both Architecture pages describe the
command.

Closes #154

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@D0n9X1n D0n9X1n added this to the v0.4.6 milestone Oct 3, 2026
@D0n9X1n D0n9X1n added enhancement New feature or request dev:windows Taken by the Windows development session labels Oct 3, 2026
D0n9X1n and others added 3 commits October 3, 2026 08:42
terminalText makes the strings in GitHub's answer terminal-safe but
does not redact them, and only the network-error line was checked for
the token. An HTTP 200 answer that echoed the token in copilot_plan,
access_type_sku, quota_reset_date or a quota id was printed by both
the report and --json.

loadCopilotUsage now searches every kept string and quota id for the
token after terminalText has run. It refuses such an answer with one
fixed line instead of printing any of it, because a redacted plan or
quota id would print as if it were data. The network-error line is now
made terminal-safe before the token is redacted from it, so a hidden
character inside a quoted token cannot keep it out of the match. A
token with no visible character cannot show in printed text, and is
not searched for: every text contains its empty visible form.

Unit tests cover an echo in each kept string, in a quota id and behind
a hidden character, a quoted token split by a hidden character, and a
token with no visible character. Integration tests cover the report and
the --json output. Both Logging-Troubleshooting pages list the new
failure line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The --json paragraph said that every quota id maps to an object with
every snapshot key. parseSnapshot makes a snapshot that is absent, or
is not a JSON object, null. Both Logging-Troubleshooting pages now say
that each known quota id is always present, that an absent or mistyped
snapshot is null, and that a snapshot object always has all eight keys,
with a missing or mistyped field null.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Some strings and one regular expression in tests/unit/usage.test.ts
held a literal zero-width space or right-to-left override. They are now
written as Unicode escapes, which compile to the same characters. An
invisible character in source does not survive an editor, a diff view
or a paste reliably; src/lib/redact.ts writes its patterns with escapes
for the same reason.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
D0n9X1n and others added 2 commits October 3, 2026 10:06
showsToken compared each printed string with the token exactly, and a
failure line had only exact copies of the token redacted. An answer
with a space inside the token, or with the token spread over
copilot_plan and access_type_sku, was printed by the report and by
--json, and a network error's reason that quoted the token with a
space inside it was printed as it was.

Both checks now also compare letters and digits alone: every other
character is removed from the token as a terminal shows it, and from
the text. showsToken searches the plan, SKU, reset date and quota ids,
joined in the order --json prints them, and refuses a match with the
existing fixed line. A failure line that still spells the token after
the exact replacement prints "Could not reach GitHub." without its
reason. The comparison applies only to a token with at least 16
letters and digits, so ordinary text cannot match a short one by
chance. The exact checks are kept, and an accepted answer is printed
unchanged. The detection is bounded to separators inside the token and
a span across the printed strings; a token written any other way, such
as in another case or encoding, is not found.

Unit tests cover a space inside a field, the token spread over two
fields, a network error's reason with a space inside the token, a
stored token with a character terminalText removes, an answer that
does not show the token, and a short token, which is matched only as
an exact copy. Integration tests cover the spread token in the report
and in --json. The test oracles also compare letters and digits alone.
Both Logging-Troubleshooting pages describe the check and the new
line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The short-token test stored a token with fewer than 16 letters and
digits that the fixture's text spells only by chance. It still passed
with the threshold raised from 16 to 17, and no test refused a short
token by its exact copy.

Three tests now sit at the threshold: a token with exactly 16 letters
and digits is found with a space inside it; a token with exactly 15 is
not, so that answer prints unchanged; and an exact copy of the token
with 15 is still refused, which only the exact check can do. Raising
the threshold to 17, lowering it to 15, or removing the exact check
each fails one of them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@D0n9X1n
D0n9X1n merged commit 34ef69e into main Oct 3, 2026
6 checks passed
@D0n9X1n
D0n9X1n deleted the feat/usage-command branch October 3, 2026 18:12
D0n9X1n added a commit that referenced this pull request Oct 3, 2026
getCopilotUsage (#156) called the global fetch, so copilot-relay usage ignored
upstreamProxy, and behind a proxy it could not reach GitHub. It now goes through
fetchUpstream, like every other GitHub call.

usage writes no file, so it cannot use readAppConfig, which creates or completes
config.yaml. readExistingAppConfig resolves an existing config.yaml without
rewriting it and returns undefined when there is none, so the request then
connects directly. A config.yaml that cannot be read or is invalid gets one
fixed line that names the path and repeats nothing from the file. A malformed
proxy variable with upstreamProxy: env gets the fixed message of the new
InvalidProxyEnvironmentError. usage then builds its dispatcher with
configureUpstreamDispatcher before the request, and the whole command runs under
withoutLogging, so a line the request would log, such as the ignored-proxy hint,
never reaches the log file. The token guard is unchanged.

The usage tests stubbed GitHub on the global fetch, which no longer intercepts
the request, so they would have reached api.github.com. They now use the
network fixture: a local stand-in behind redirectGitHubTo, and
refuseExternalConnections in the test process and in each CLI child. Every
assertion is kept, the #156 threshold tests included, and so are the checks that
usage makes no token exchange and no /user call. A failure the old stub threw
is now a dispatch that fails before it is sent, and the timeout test lets the
stand-in leave the request unanswered with the deadline shortened to 50 ms.

The docs cover the usage route, its read-only config read and its two new
failure lines, and the README no longer says the request goes to GitHub
directly. An Internals note explains why a stand-in started with a top-level
await must come before the first test(): node:test runs the after() hooks once
every test registered so far has finished, and one registered later never runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dev:windows Taken by the Windows development session enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Show the Copilot plan and quota with copilot-relay usage

1 participant