fix: v2.5.9 — Vietnamese in the terminal on macOS, every slash command, the right Claude account
Issue #62: a tmux client whose LC_ALL/LC_CTYPE/LANG names no UTF-8 prints `_` for every non-ASCII character, and a macOS LaunchAgent starts the dashboard with no locale at all — Vietnamese text and Claude Code's logo came out as underscores in every browser terminal. - shell-path.js: ensureUtf8Locale() defaults LC_CTYPE (en_US.UTF-8 on macOS, C.UTF-8 elsewhere) at start, before any tmux call; an explicit LC_ALL and Windows are left alone - RunSetup.tsx: the `/` list now offers Claude Code's bundled skills (/code-review, /security-review, /simplify, /loop…) and newer built-ins, and every enabled plugin's commands and skills as /<plugin>:<name> - Claude login decided by the TOKEN's owner (GET /api/oauth/profile, cached per token), not by .claude.json: a running session can leave the credentials store holding one account's token under another account's name. The Profiles modal, its Check quota and the status-bar quota now show the account requests are really billed to and warn on the mismatch; a switch saves the outgoing token into its OWNER's profile (credentials only) instead of overwriting the profile .claude.json names; saving a profile while they disagree is refused (EMISMATCH); a saved profile holding another account's token reports EWRONGACCOUNT instead of that account's usage - Running Claude Code sessions follow a switch on their own (they re-read the credentials store when it changes), so the "close your sessions first" warning is gone
This commit is contained in:
+63
-11
@@ -248,12 +248,14 @@ function freeName(base) {
|
||||
* terminal.
|
||||
*
|
||||
* @param stored a profile as returned by `readProfile`
|
||||
* @param opts.owner the live token's owner (`cc-usage.liveOwner`), when known
|
||||
* @returns the live `claudeAiOauth` block, or null when this is not that account
|
||||
*/
|
||||
function liveCredentialsFor(stored) {
|
||||
function liveCredentialsFor(stored, { owner = null } = {}) {
|
||||
const live = readActiveAuth();
|
||||
if (!live.oauth) return null;
|
||||
const mine = identityOf(live).accountUuid;
|
||||
// The token's owner when known: `.claude.json` can name another account.
|
||||
const mine = owner?.uuid || identityOf(live).accountUuid;
|
||||
const theirs = identityOf({ oauth: stored.credentials, account: stored.account }).accountUuid;
|
||||
return mine && mine === theirs ? live.oauth : null;
|
||||
}
|
||||
@@ -269,8 +271,25 @@ function readProfile(name) {
|
||||
* @returns `{ items, active }` — `items[].active` marks the one whose
|
||||
* `accountUuid` matches the live config. Tokens are never included.
|
||||
*/
|
||||
function listProfiles() {
|
||||
const active = identityOf(readActiveAuth());
|
||||
function listProfiles({ owner = null } = {}) {
|
||||
const live = readActiveAuth();
|
||||
const configured = identityOf(live);
|
||||
// The token is what Claude Code runs as; `.claude.json` is only a label, and
|
||||
// a running session can leave it naming a different account.
|
||||
const mismatch =
|
||||
owner?.uuid && configured.accountUuid && owner.uuid !== configured.accountUuid
|
||||
? { configEmail: configured.email, configUuid: configured.accountUuid }
|
||||
: null;
|
||||
const active = mismatch
|
||||
? {
|
||||
...configured,
|
||||
accountUuid: owner.uuid,
|
||||
email: owner.email || null,
|
||||
displayName: null,
|
||||
organization: null,
|
||||
seatTier: null,
|
||||
}
|
||||
: configured;
|
||||
let names = [];
|
||||
try {
|
||||
names = fs
|
||||
@@ -304,7 +323,17 @@ function listProfiles() {
|
||||
// Label first (that is what the UI shows), name as the tiebreaker so two
|
||||
// profiles sharing a label still come back in a stable order.
|
||||
items.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name));
|
||||
return { items, active: active.accountUuid || active.email ? active : null };
|
||||
// The owner's own saved identity reads better than the bare uuid + email.
|
||||
const mine = mismatch && items.find((p) => p.active);
|
||||
const shown = mine
|
||||
? {
|
||||
...active,
|
||||
displayName: mine.displayName,
|
||||
organization: mine.organization,
|
||||
seatTier: mine.seatTier,
|
||||
}
|
||||
: active;
|
||||
return { items, active: shown.accountUuid || shown.email ? shown : null, mismatch };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,7 +350,7 @@ function listProfiles() {
|
||||
* afterwards instead of having to invent a filename up front.
|
||||
* @returns `{ ok, name, label, ...identity }`
|
||||
*/
|
||||
function saveProfile(name) {
|
||||
function saveProfile(name, { owner = null } = {}) {
|
||||
const auth = readActiveAuth();
|
||||
if (!auth.oauth) {
|
||||
throw err(
|
||||
@@ -329,6 +358,15 @@ function saveProfile(name) {
|
||||
"no Claude account is logged in on this machine - run `claude auth login` first"
|
||||
);
|
||||
}
|
||||
const configUuid = auth.account && auth.account.accountUuid;
|
||||
if (owner?.uuid && configUuid && owner.uuid !== configUuid) {
|
||||
throw err(
|
||||
"EMISMATCH",
|
||||
`the token logged in now belongs to ${owner.email || "another account"}, not ${
|
||||
auth.account.emailAddress || configUuid
|
||||
} - switch to that account's profile, or run \`claude auth login\` again`
|
||||
);
|
||||
}
|
||||
const identity = identityOf(auth);
|
||||
const readable = identity.displayName || (identity.email || "").split("@")[0] || "profile";
|
||||
// An explicit name is both identifier and initial label; a derived one keeps
|
||||
@@ -405,22 +443,36 @@ function renameProfile(name, label) {
|
||||
* @param name Profile to activate.
|
||||
* @returns `{ ok, name, savedOutgoing, written }`
|
||||
*/
|
||||
function activateProfile(name) {
|
||||
const target = readProfile(name);
|
||||
if (!target || !target.credentials) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
function activateProfile(name, { owner = null } = {}) {
|
||||
if (!readProfile(name)?.credentials) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
|
||||
const current = readActiveAuth();
|
||||
let savedOutgoing = null;
|
||||
if (current.oauth) {
|
||||
const mine = listProfiles().items.find((p) => p.active);
|
||||
const { items, mismatch } = listProfiles({ owner });
|
||||
const mine = items.find((p) => p.active);
|
||||
if (!mine) {
|
||||
throw err(
|
||||
"EUNSAVED",
|
||||
"the account logged in right now is not saved as a profile - save it first, or its credentials are lost on switch"
|
||||
);
|
||||
}
|
||||
if (mine.name !== name) savedOutgoing = saveProfile(mine.name).name;
|
||||
if (mismatch) {
|
||||
// The live token belongs to `mine`, but `.claude.json` names someone
|
||||
// else: keep that profile's own identity and take only the token — the
|
||||
// newest one that account has (a refresh retired the saved copy). Done
|
||||
// even when `mine` IS the target, so the switch writes that newest token.
|
||||
writeProfile(mine.name, {
|
||||
...readProfile(mine.name),
|
||||
credentials: current.oauth,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
savedOutgoing = mine.name;
|
||||
} else if (mine.name !== name) {
|
||||
savedOutgoing = saveProfile(mine.name).name;
|
||||
}
|
||||
}
|
||||
const target = readProfile(name);
|
||||
|
||||
const written = [];
|
||||
const { creds: live, keychain } = readCredentials();
|
||||
|
||||
Reference in New Issue
Block a user