Sign in to a service
How a tool signs in to a provider, keeps its keys and tokens out of the store and the screen, and always names the profile.
Your tool talks to a service that needs a sign-in, such as Google or Zoom. This page shows where the keys and tokens live, how the owner chooses the account, and what a sign-in must never do. It also shows the shared sign-in module in cli-core.
The rules
- The profile is always explicit. The owner names the profile in every command, such as
acme work sync. A tool never picks the first profile in a list, and a missing profile is an error that lists the choices (accounts rules). - Secrets come from the environment, then the OS keyring, then a private file (WC-SECR-4).
- A secret is never taken on the command line, because it stays in the shell history (WC-SECR-5).
- A token stays inside the transport. It never reaches the store, a log, an error or the output (WC-SECR-7).
Steps
- Ask the owner for the secret with a hidden prompt, or start the provider's sign-in page in the browser.
- Save the token with
Credentialsfrom@wirecat/cli-base, keyed by the profile name. - Read it back by the same profile when a command needs it, and pass it only to the code that calls the service.
Worked example
import { Credentials } from "@wirecat/cli-base"
const credentials = new Credentials({ service: "acme-cli", configDir, envVar: "ACME_TOKEN" })
credentials.write(profile, token)
const saved = credentials.read(profile)profile is the name the owner typed. saved.secret goes to the service client and nowhere else; saved.source
says whether it came from the environment, the keyring or the file.
Browser sign-in with the shared module
For a service that signs in through its web page (OAuth), use the shared sign-in module @wirecat/cli-core/oauth.
It gives every tool one flow:
- the browser sign-in with a local return address or a pasted code, protected by
stateand PKCE; - token exchange, refresh and sign-out;
- a lock per profile, so two commands never refresh the same token at once;
- your tool declares its provider; cli-core holds no provider's addresses or ids, and any step can be replaced;
- one naming scheme for keyring entries; an older entry can still be read, so no one has to sign in again.
import { credentialStorage, type OAuthProvider, oauthAccount, standardCodec, tokenKeeper } from "@wirecat/cli-core/oauth"
const acme: OAuthProvider = {
id: "acme",
name: "Acme",
authorizeUrl: "https://acme.example/oauth/authorize",
tokenUrl: "https://acme.example/oauth/token",
client: { clientId: "your-app-id" },
redirect: { kind: "loopback" },
}
const keeper = tokenKeeper({
provider: acme,
storage: credentialStorage({
credentials: { service: "acme-cli", configDir, envVar: "ACME_TOKEN" },
account: oauthAccount(acme.id, profile),
codec: standardCodec(acme.id),
}),
lockDir: configDir,
fetch,
signInCommand: `acme ${profile} sign-in`,
})
await keeper.signIn({ scopes: ["meetings:read"], openBrowser })
const accessToken = await keeper.accessToken()configDir and profile are as above; openBrowser opens a URL in the owner's browser. accessToken() refreshes
the token when it is about to expire. The token is saved with Credentials, so the rules above still hold.
What breaks if you skip this
A default profile reads or writes the wrong account. A token on the command line stays in the shell history. A token in an error message reaches the agent's chat and its logs.
Next: Stay compliant and current.