Repository navigation
feat(cli): let multi-account users act on another account per command [RED-1122] [ship] - #1528
Conversation
sorccu
left a comment
There was a problem hiding this comment.
Thanks, this is a clear improvement for agents working across accounts, and the blind-test write-up is convincing. I ran a multi-lens review (correctness, intent, simplicity) with adversarial verification of the major findings. The inline comments cover the code-level items. The summary:
Major (verified)
CHECKLY_ACCOUNT_IDoverride vs. existing auth branches (seeconfig.ts): onlywhoamiand the not-found hint know aboutaccount_override. Error messages inapi.ts, the inline-login trigger andlogin'senv_credentialsrefusal still treat the prefix as API-key env credentials. These paths predate this PR, but the new skill guidance sends agents straight into them.- Public docs not updated or tracked:
docs/cli/checkly-login.mdx(no--wait),checkly-whoami.mdx(no--output json, default, or other accounts),checkly-switch.mdx(no non-TTY/agent behaviour) andauthentication.mdx(presentsCHECKLY_ACCOUNT_IDonly as half of an API-key pair) all describe the old behaviour. Please add a docs PR or a follow-up ticket and link it here.
Minor: the inline comments cover the default reported under the override in switch/whoami, races in --wait, a missing onUnexpectedAnswers, test gaps, and duplicated hint formatting.
PR description:
- Please replace the linear.app link with the bare
RED-1122ID. This repo is public, and PR bodies stay public. - The test list still mentions
config.hasAccountOverride, which the last commit replaced withgetCredentialSource.
| * credentials from `CHECKLY_API_KEY` / `CHECKLY_ACCOUNT_ID`, whose key | ||
| * belongs to one account. | ||
| */ | ||
| getCredentialSource (): CredentialSource { |
There was a problem hiding this comment.
Major: account_override is only consulted by whoami and notFoundHint(). Every other auth branch still uses hasEnvVarsConfigured(), so the CHECKLY_ACCOUNT_ID=<id> prefix that the skill now recommends is still handled as "API-key credentials from the environment" there:
rest/api.ts:85blanks the hint when the id is mistyped or belongs to an account the user isn't in. The agent then gets a bare Forbidden/NotFound instead ofAccount "x" is not available with the stored login. That is exactly the cross-account case this PR targets.- With no stored key (after
logout, or on a fresh machine),authCommand.ts:32skips the inline login andrest/api.ts:66reports "CHECKLY_API_KEYis not set". The agent is pointed at API keys instead of the device flow. login.ts:270refuses withenv_credentialsand says to delete the variables, although the override is just a one-command prefix.
All three behaviours already exist on main, but the new guidance makes them much easier to hit. Suggest routing at least the api.ts hint and missing-key logic through getCredentialSource(), or documenting these cases in the skill.
There was a problem hiding this comment.
Done in 8e1e0c8 and c3514a2. Only CHECKLY_API_KEY now counts as environment credentials; config.getCredentialSource() (login | account_override | environment) is the single check. A CHECKLY_ACCOUNT_ID alone picks the account for the user's login, for every command while it's set, since the CLI can't tell a prefix from an export or .env.
api.ts: an id the login doesn't have now fails with the accounts it does have: "Account "x" fromCHECKLY_ACCOUNT_IDis not available with your login. This login works with: …".- No stored key: an authenticated command starts the inline device login, then uses the variable's account without storing a default. CI keeps an error, now naming both options (log in, or set
CHECKLY_API_KEYas well). loginonly refuses forCHECKLY_API_KEY; with the variable it finishes without asking for an account.login/switchreason about the stored login and warn that the variable takes precedence over the default they store;whoamisays where the account comes from.
| private reportChoices (accounts: Account[], mode: CliMode): never { | ||
| if (mode === 'agent') { | ||
| this.log(selectAccountLine({ | ||
| message: `The default account is "${this.account.name}". Ask the user which account to switch to, then run ` |
There was a problem hiding this comment.
Under CHECKLY_ACCOUNT_ID=B npx checkly switch, this.account is the override (B), so this line says The default account is "B" and currentAccount is B, while the stored default is still A. An agent may then skip switch --account-id B or report the wrong default to the user. Read the default from config.data.store as whoami does, or word this as the account in use for this command.
There was a problem hiding this comment.
Fixed in 8e1e0c8: the default now comes from config.data.store, and the field is defaultAccount (null when none is stored). Under CHECKLY_ACCOUNT_ID the message also says the variable takes precedence. Covered by "reports the stored default, not the account CHECKLY_ACCOUNT_ID picks for this command" in switch.spec.ts.
| account: { id: account.id, name: account.name, plan: account.planDisplayName ?? null, addons: addonNames }, | ||
| accountSource, | ||
| // The account `checkly login` stored; none until one is chosen. | ||
| defaultAccount: defaultAccountId |
There was a problem hiding this comment.
Text output hides the default when accountSource === 'environment' (env credentials ignore it), but JSON still reports a stale stored defaultAccount. With CHECKLY_API_KEY=k CHECKLY_ACCOUNT_ID=B, the output is accountSource: "environment" alongside defaultAccount: {id: A}, which contradicts the skill's description of defaultAccount. Suggest emitting null in that case, matching the text branch.
There was a problem hiding this comment.
Fixed in 8e1e0c8: defaultAccount is null for accountSource: "environment", matching the text output. Test added in whoami.spec.ts.
| ): Promise<Credentials | 'stored'> { | ||
| let tokens | ||
| try { | ||
| tokens = await deviceFlow.pollForTokens(pending) |
There was a problem hiding this comment.
pollForTokens is called here without onUnexpectedAnswers, unlike the interactive path at L577. If a proxy or captive portal answers the token endpoint, --wait blocks silently for the whole expires_in and then fails with expired_token, giving no hint that the network is the cause. Suggest passing the callback and emitting a JSON progress/network_error line, or stopping early.
There was a problem hiding this comment.
Fixed in 8e1e0c8: --wait now passes onUnexpectedAnswers. It keeps polling, like the interactive path, since a captive portal can clear. If the code expires after such answers, it reports network_error naming the answer and a likely proxy or captive portal, instead of a bare expired_token. Test: "names a proxy or captive portal when the code expires while answers come from something else".
| * used), so it is done either way. Returns 'stored' when another run used | ||
| * it and stored its key; rethrows otherwise. | ||
| */ | ||
| #pendingCodeFailed (error: unknown): 'stored' { |
There was a problem hiding this comment.
Two races that --wait now makes much more likely, because a poller is held open for minutes:
- Stale failure: another run (an inline login using
#collectPendingAuthorization) consumes the code first and is still exchanging tokens for the API key. This poller's next request getsinvalid_grant,config.getApiKey()is still empty, and it prints an error for a login that succeeds a moment later. Consider re-checking for the key over a short bounded window before rethrowing. - Deleting a newer code: the deletes here and at L655 are unconditional. If another run replaced the near-expiry code X with a new code Y (the
MIN_RELAY_TIME_MSpath), this run's eventualexpired_tokendeletes Y, which the user may be approving at that moment. Only delete when the storeddeviceCodematches the one polled.
There was a problem hiding this comment.
Both fixed in 8e1e0c8:
- Stale failure: on
invalid_grantwith no key yet, it waits up to 15 s for the other run's key before failing. - Deleting a newer code: the stored code is only forgotten if its
deviceCodeis the one this run polled (#forgetPendingAuthorization), on every path, including the near-expiry one.
Tests cover both, plus the case where no key turns up in time.
| this.style.shortError(`Your Checkly configuration file is not valid.`) | ||
| return this.exit(1) | ||
| } | ||
| const hint = err instanceof NotFoundError ? this.notFoundHint() : undefined |
There was a problem hiding this comment.
This is the path most commands depend on for the hint (e.g. test-sessions get on the wrong account), but no spec covers it. Removing it would leave the suite green. Suggest a spec that calls catch() with a NotFoundError, both with and without environment credentials.
There was a problem hiding this comment.
Added AuthCommand.catch specs in auth-command-login.spec.ts: the hint is added for a login key, and the error is left alone for API key credentials and for errors other than not-found.
| } catch (err: any) { | ||
| if (err instanceof NotFoundError) { | ||
| this.style.shortError(`Check "${args.id}" not found. It may have already been deleted.`) | ||
| this.style.shortError([`Check "${args.id}" not found. It may have already been deleted.`, this.notFoundHint()] |
There was a problem hiding this comment.
This hand-built [msg, this.notFoundHint()].filter(Boolean).join(…) is repeated in rca/run.ts, with a different separator (' ' here, '. ' there) and no test for the rca one. A withNotFoundHint(message) helper on BaseCommand would remove the duplication, and future custom not-found messages would be less likely to forget the hint.
There was a problem hiding this comment.
Done in 8e1e0c8: BaseCommand.withNotFoundHint(message) is now used by catch(), checks delete and rca run, with one separator. The new rca-run.spec.ts covers the rca message.
| } | ||
|
|
||
| longError (title: string, message: string | Error) { | ||
| const hint = message instanceof NotFoundError ? this.c.notFoundHint() : undefined |
There was a problem hiding this comment.
In JSON mode the hint is English prose appended to detail, so an agent has to parse a sentence to learn which account was searched. A structured field (e.g. searchedAccount: {id, name}, or a separate hint) would be a stable contract. It would also fit the structured choices/next in select_account, and the planned follow-up of listing other accounts.
There was a problem hiding this comment.
Done in 8e1e0c8: JSON errors now carry hint and searchedAccount: {id, name} as their own fields, and detail stays the API message. Both are left out when there's nothing to hint. The skill documents them.
|
Thanks for the thorough review, @sorccu. I addressed all items; the replies are on each thread. Summary: Major
Minor: all fixed. See the threads. PR description: the linear.app link is replaced with the bare ID, and the test list is current. Verification: described in the PR description. It covers:
Two follow-ups came from those agent runs: c3514a2 (explicit |
|
Thanks for the thorough follow-up, @hlenke. Every thread is addressed, and the
The branch is also behind I'll take the PR from here: rebase it, make these final changes myself, and update the description when I push. |
… [RED-1122] After a device-flow login, a user with several accounts could only get past `select_account` by storing a default with `login --account-id`, so cross-account work kept rewriting it. The stored login key belongs to the user, and `CHECKLY_ACCOUNT_ID=<id>` already runs one command against any of their accounts; nothing pointed to it. - `select_account` offers `CHECKLY_ACCOUNT_ID=<id> <original command>` next to `login --account-id <id>`; inline logins pass their command line. - `checkly login --wait` polls the code an earlier run showed, so an agent relays the code and waits instead of asking the user to say when done. `login_required.next` points to it. - Not-found errors from a login key name the account searched and how to look in another; `whoami` lists the other accounts and says when `CHECKLY_ACCOUNT_ID` picks the account for this command only. - Skill and initialize reference document both. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… [RED-1122] A blind agent test of the multi-account flow found two gaps: `whoami`, the command the skill sends agents to for checking the account, only printed prose, and nothing said what the default is after a multi-account login. `whoami --output json` reports the user, the account, `accountSource` (`login`, `account_override` or `environment`), the stored `defaultAccount` (null until chosen) and the other accounts. The skill says no account is the default until one is chosen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…terminal [RED-1122] `checkly switch` without `--account-id` drew its account menu even in agent and CI mode or without a terminal, where nobody can answer it; a blind agent test saw it exit having done nothing. It now prints the `select_account` line `checkly login` uses (plus `currentAccount`) to an agent and fails with the accounts and `--account-id` elsewhere. The skill now says an account name or id the user gave that matches exactly one choice counts as their choice, and points to `switch`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… whoami [RED-1122] Findings from 40 blind agent runs: - The skill only mentioned `switch --account-id` inside the post-login `select_account` bullet, so agents guessed whether it also changes an existing default and whether it needs confirmation. A "Working with several accounts" section now covers listing, one-command use, changing the default and not-found lookups. - "Wait with a timeout of at least expires_in" is impossible in shells capped below 900s; run `login --wait` in the background and rerun it. - Plain `whoami` did not say whether the account is the default; it now prints a `Default account:` line (none for env credentials). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…1122] Cleanups from a /simplify pass over the branch: - `selectAccountLine()` and `formatAccounts()` build the agent line and account lists for both `login` and `switch`, so the two copies of the `select_account` contract cannot drift (their `when` text already had). - `config.getCredentialSource()` replaces `hasAccountOverride()` and the raw `CHECKLY_API_KEY` checks in `whoami` and the not-found hint. - One helper handles a refused stored device code for both the one-shot and the `--wait` collection; `--wait` is passed instead of stored. - `switch` detects the CLI mode once; `whoami` fetches the user and the accounts in parallel and reads the stored default once. - `checks delete` and `rca run`, which print their own not-found message, add the hint too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…RED-1122] Review feedback: only whoami and the not-found hint knew that a `CHECKLY_ACCOUNT_ID` without `CHECKLY_API_KEY` picks an account for the user's login; every other path treated it as API key credentials. The CLI cannot tell a one-command prefix from an export or a `.env` entry, so this now holds for every command while the variable is set: - Only `CHECKLY_API_KEY` counts as environment credentials. Without it, an authenticated command starts the login (CI keeps an error naming both ways to authenticate), `login` no longer refuses, and an inline login uses the variable's account for that command without storing a default. - An id the login does not have fails listing the accounts it does have. - `login` reasons about the stored login, not the variable, and `login`, `switch` and `whoami` say the variable takes precedence over the default. `switch` reports the stored `defaultAccount`; `activateAccount` checks the account it activates. - `whoami --output json` reports no default for API key credentials. - `--wait` names a proxy or captive portal when the code expires while answers come from something else, waits briefly for the key of a run that used the same code, and only forgets the code it polled. - Not-found hints: `withNotFoundHint()` for commands with their own message, `hint` and `searchedAccount` JSON fields, and tests for the `catch()` path and `rca run`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…an account [RED-1122] Blind agent runs (5/5) hit `select_account` (exit 1) from `login --wait` when the project's `.env` already pinned `CHECKLY_ACCOUNT_ID`, and had to infer that they could go on. The variable names the account commands use, so an explicit login now finishes like the inline one: it uses that account and stores no default (`--account-id` still stores one). The skill now says when `whoami` reports `defaultAccount: null` and that a command-line prefix wins over the value in `.env`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ks the account [RED-1122] Two of five blind agent runs read whoami's "Default account: none; choose one with ..." as a hint to store a default they did not need, since the project's .env already picked the account. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… [RED-1122] The reworded warning wraps inside "environment variables", so the plain substring match in the login e2e failed on Ubuntu and Windows. Collapse the continuation prefixes (› / ») before matching, in logout too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… auth [RED-1122] Cleanups from a second /simplify pass: - `helpers/accounts.ts` holds `formatAccounts()`, `accountOverrideNotice()` and `accountOverrideNotAvailable()`. The override notice was built four times, account lists three ways, and the "not available" message twice with different wording (login now uses the API's). - `config.getStoredAccount()` replaces raw `config.data.store` casts in `whoami` and `switch`. - `validateAuthentication` throws the unauthorized error once. - Clearer control flow: `whoami`'s default line and `login`'s wait for another run's key; `login` reads the stored login once. - The override notice on login's `select_account` path is gone: that path is unreachable while `CHECKLY_ACCOUNT_ID` is set. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…OUNT_ID [RED-1122] A login that `checkly switch` starts inline while CHECKLY_ACCOUNT_ID is set stores no default, so switch must still ask for one (or list the choices to an agent) instead of returning as if the login had chosen it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nswers continue [RED-1122] `login --wait` blamed a proxy or captive portal for any expiry once unexpected answers had occurred, even after the login server answered again and the code simply went unapproved. `pollForTokens` now decides: the expiry is a `network_error` only while the run of unexpected answers is still going, for every caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e [RED-1122] A login under CHECKLY_ACCOUNT_ID returns the same success line as one that stores a default, so the skill's claim that a success makes the account the default was wrong there. Success lines now carry `defaultAccount` (the stored default, or null), and the skill describes it. That login also keeps the notice saying why the stored login was no longer usable; `#withNotice` puts the notice in front of every agent message that can follow it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t [RED-1122] A lone CHECKLY_ACCOUNT_ID no longer stops the inline login, so the CI check is all that keeps a job missing its CHECKLY_API_KEY secret from waiting on a device code nobody sees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alize [RED-1122] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also marks the device-code cleanup as best effort: its compare and delete are separate file operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
34c28c8 to
dd38da2
Compare
Problem
RED-1122: after a device-flow login (#1484), a user with several accounts gets
select_accountfrom every command, includingwhoami. The only way out the CLI suggests islogin --account-id, which stores a default, so an agent doing cross-account work (reproducing in one account, looking up a CI test session in another) switched the default four times. A cross-account lookup just said "No such test session." Separately, an agent had to wait for the user to say "done" after relaying the login code.The stored login key belongs to the user, so
CHECKLY_ACCOUNT_ID=<id>can pick any of their accounts. Onmain, though, only the stored-login path knew this; everywhere else (login, the inline login, error messages) aCHECKLY_ACCOUNT_IDwas treated as half of an API-key pair.Changes
Accounts and credentials
CHECKLY_API_KEYcounts as environment credentials (config.getCredentialSource():login|account_override|environment). ACHECKLY_ACCOUNT_IDalone picks the account for the user's login, for every command while it is set. The CLI can't tell a one-command prefix from an export or a.enventry.loginno longer refuses. Without--account-idit finishes without asking for an account, since the variable already names it.login,switchandwhoamisay when the variable takes precedence over the stored default.loginandswitchreason about the stored login, andactivateAccountchecks the account it activates.switchstill asks for a default after an inline login under the variable, which stores none.--helpdescribesCHECKLY_ACCOUNT_IDon its own.select_accountoffers a per-command choice, next to making the account the default (login --account-id <id>). An inline login passes in its own command line.switchwithout--account-idno longer draws a menu nobody can answer (agent mode, CI, no terminal). An agent getsselect_accountwith the storeddefaultAccount; CI and terminal-less runs get an error listing the accounts.whoami--output jsonreportsuser,account,accountSource,defaultAccountandotherAccounts.defaultAccountisnullfor API key credentials.Login
checkly login --waitwaits for the approval of the code an earlier run printed, so the user doesn't have to say "done".login_required.nextpoints to it.network_errorinstead of a bareexpired_token.pollForTokensdecides this, so it applies to every login that polls, and a code that simply went unapproved after such answers cleared staysexpired_token.Agent success lines carry
defaultAccount(the stored default, ornull): a login underCHECKLY_ACCOUNT_IDreturnslogged_inwithout storing one. The "account no longer available" notice stays in its message.Not found
--output json, they also carryhintandsearchedAccount: {id, name}fields.BaseCommand.withNotFoundHint()covers commands with their own not-found message (checks delete,rca run).Docs
--wait, a "Working with several accounts" section (listing, one-command use,CHECKLY_ACCOUNT_IDin the shell or.env, changing the default, not-found lookups), and a note that a name the user gave counts as their choice if it matches exactly one account.skills/checkly/SKILL.mdis synced.Deliberately left out
--account-idflag: the name collides withaccount usage series --account-id(a filter) and with the persisting flag onlogin/switch.logoutclears the config, and a fresh machine has nothing to remember. A re-login underCHECKLY_ACCOUNT_IDafter the stored login became unusable stores no default either; an agent login spans two runs, and the first has already dropped the old one.checkly-login,checkly-whoami,checkly-switch,authentication): docs(cli): document multi-account use, login --wait and whoami --output json [RED-1122] docs#566, to merge once this ships.How this was tested
Automated
config.getCredentialSource).validateAuthenticationby source, including the account list for a wrongCHECKLY_ACCOUNT_ID.login:--waitapproval, network failure, both races and the key-wait timeout; the inline and explicit login underCHECKLY_ACCOUNT_ID; refusal only for API keys; the override notices.switch: agent, CI and terminal-less paths; the stored default under the override; the warning; choosing a default after an inline login under the override.pollForTokens:network_erroronly while unexpected answers continue at expiry.whoami: text and JSON for all sources.AuthCommand.initin CI with onlyCHECKLY_ACCOUNT_ID.AuthCommand.catchandCommandStyle.longErrorhints in text and JSON,checks delete,rca run.login-device-flow.spec.ts, fake Auth0 + API, 9 cases):login --wait.CHECKLY_ACCOUNT_IDper command..envpinning the account on a fresh machine.pnpm test(4262 passed),pnpm lint.Scenario matrix
12 scripted cases were run against both a
mainbuild and this PR, using a fake API. A login key works with all of the user's accounts; an API key works with one.main.envpins an accountCHECKLY_API_KEYis not set"CHECKLY_ACCOUNT_IDonlyCHECKLY_API_KEYis not set"CHECKLY_API_KEYCHECKLY_ACCOUNT_ID=x checkly loginenv_credentials)already_logged_inplus a note that the variable winsCHECKLY_ACCOUNT_IDNotFoundError: Account not foundswitch(agent mode or CI)select_accountwithdefaultAccount; CI: error listing the accountsswitch --account-idunderCHECKLY_ACCOUNT_ID--output json{"error","detail"}hintandsearchedAccountloginrefusesBlind agent benchmark
Each fresh Claude agent got a realistic user request, a sandbox shell, the skill of the build under test, and a fake Auth0 + Checkly API. The fake "user" approves a login 20 s after the code is issued and never replies. Each run had its own server, port and HOME, and both builds were installed from packed tarballs outside any git repo. Every run was graded by a script against the fake API's request log and the CLI's saved config, plus a check that the project's
.envwas left unchanged..envpins the account: "three most recent sessions for this project".envpins account A, default is B: "make Staging my default, then show its newest sessions"ts-ci-123in acc-e2e-old"On
mainthe agents got there through workarounds:CHECKLY_ACCOUNT_IDor ranloginoutside the project to get past "CHECKLY_API_KEYis not set", waited withsleep, and 2 of 5 stored a default as a side effect.With the PR, every agent followed the CLI's
nextentries and messages.Agent feedback led to the last two commits:
select_accountafterlogin --wait, even though.envalready named the account. A rerun of U1 (5/5) after the fix no longer hit it.whoaminudged agents towards storing a default they didn't need.🤖 Generated with Claude Code