From 888a908c56346d65b10842649a9cb115b7ef02a0 Mon Sep 17 00:00:00 2001 From: Paulo Carinhena <63272749+paulocarinhena@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:11:19 -0300 Subject: [PATCH 1/2] =?UTF-8?q?Adicionada=20remo=C3=A7=C3=A3o=20de=20conta?= =?UTF-8?q?s=20extras=20de=20Claude=20e=20Codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O botão "+" da widget do Windows agora abre a janela Accounts: adiciona uma conta nova e lista as já adicionadas, cada uma com uma lixeira que pede confirmação antes de apagar a pasta (com o login) e o comando, como claude2. - scripts/remove-account.ts: --list para as widgets e a remoção em si; recusa a pasta da conta principal, inclusive quando CLAUDE_CONFIG_DIR/CODEX_HOME aponta para uma pasta com nome de conta extra - scripts/account-kinds.ts: o que add-account e remove-account compartilham - CI: remoção testada de ponta a ponta num HOME temporário - Installation.md: como remover uma conta Co-Authored-By: Claude Opus 5.5 --- .github/workflows/macos.yml | 13 ++ Installation.md | 7 +- package.json | 3 +- scripts/account-kinds.ts | 87 +++++++++++ scripts/add-account.ts | 84 +--------- scripts/remove-account.ts | 151 ++++++++++++++++++ widget/usage-widget.ps1 | 302 +++++++++++++++++++++++++++++++++--- 7 files changed, 544 insertions(+), 103 deletions(-) create mode 100644 scripts/account-kinds.ts create mode 100644 scripts/remove-account.ts diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index ba24880..faef2c9 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -58,3 +58,16 @@ jobs: test "$(codex2 where)" = "$HOME/.codex-2" fails add # the name is taken now node --experimental-strip-types --no-warnings -e "import('./src/providers/index.ts').then(async (m) => { const a = (await m.fetchAllUsage('codex')).map((u) => u.account).join(','); console.log(a); if (a !== 'main@x.com,other@x.com') process.exit(1) })" + + # scripts/remove-account.ts: lists the extra account, never touches the main one, removes folder and command. + remove() { node --experimental-strip-types --no-warnings scripts/remove-account.ts "$@"; } + remove --list | grep -q '"folder":".codex-2","account":"other@x.com","commands":\["codex2"\]' + fails remove codex codex + fails remove codex .codex + mkdir -p "$HOME/.codex-main" && cp "$HOME/.codex/auth.json" "$HOME/.codex-main/" + CODEX_HOME="$HOME/.codex-main" fails remove codex .codex-main # the main folder, even named like an extra one + test -e "$HOME/.codex-main/auth.json" && rm -rf "$HOME/.codex-main" + remove codex codex2 + test ! -e "$HOME/.codex-2" && test ! -e "$HOME/.local/bin/codex2" && test -e "$HOME/.codex/auth.json" + fails remove codex codex2 # nothing left to remove + test "$(remove --list)" = "[]" diff --git a/Installation.md b/Installation.md index 934322e..b566fc4 100644 --- a/Installation.md +++ b/Installation.md @@ -299,7 +299,7 @@ The widget only **reads** credentials the tools themselves already saved on your Claude Code and Codex keep one login per config folder, so each extra account gets its own folder in your home (`.claude-2`, `.codex-work`…) and a command that opens it, like `claude2`. The widget finds these folders and shows one card per account. -- **In the widget (Windows, macOS, Linux):** click **+** in the widget header (or **Add account** in the tray / menu bar menu), pick a command name and sign in with the other account in the terminal that opens. +- **In the widget (Windows, macOS, Linux):** click **+** in the widget header (or **Accounts...** in the Windows tray, **Add Claude account…** / **Add Codex account…** in the macOS / Linux menu), pick a command name and sign in with the other account in the terminal that opens. - **Or from any terminal:** ```bash @@ -309,7 +309,9 @@ Claude Code and Codex keep one login per config folder, so each extra account ge After that, `claude` opens your main account and `claude2` the other one, in any terminal (the same goes for `codex` / `codex2`). The command lives next to `claude` on Windows and in `~/.local/bin` elsewhere. If nothing was signed in, or the browser signed in to your main account again, the new folder and command are removed; sign out in the browser (or use a private window) and try again. -Claude tokens are only renewed by Claude Code itself, so open `claude2` now and then to keep its card fresh. To remove an account, delete its folder and command. +Claude tokens are only renewed by Claude Code itself, so open `claude2` now and then to keep its card fresh. + +To remove an extra account: on Windows, click **+** and use the bin next to it under **Added accounts**; on macOS and Linux, run `npm run remove-account -- claude claude2` (or `codex codex2`). This deletes that account's folder, with its sign-in and history, and its command; your main account is never touched. ## Uninstall @@ -343,6 +345,7 @@ Layout: - `scripts/update.ts`: the version, and the self-update behind **Update now** (`check` / `apply`). - `scripts/keys.ts`: the API keys behind **Settings → API keys**: which were found, and checking and keeping a pasted one. - `scripts/add-account.ts`: adds another Claude or Codex account (folder, command, sign-in); the widgets' **+** button runs it. +- `scripts/remove-account.ts`: lists the extra accounts (`--list`) and removes one (folder and command); the Windows **Accounts** window runs it. - `widget/usage-widget.ps1`: the WPF UI. The `.vbs` only launches it without a console. - `widget/openusage.ico`: the app icon, used by the tray and the shortcuts. - `widget-mac/OpenUsage.swift`: the macOS app (AppKit + SwiftUI). `widget-mac/build.sh` compiles it into `OpenUsage.app`. diff --git a/package.json b/package.json index 3210c75..181de71 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "scripts": { "typecheck": "tsc --noEmit", "probe": "node --experimental-strip-types scripts/probe.ts", - "add-account": "node --experimental-strip-types --no-warnings scripts/add-account.ts" + "add-account": "node --experimental-strip-types --no-warnings scripts/add-account.ts", + "remove-account": "node --experimental-strip-types --no-warnings scripts/remove-account.ts" }, "devDependencies": { "@types/node": "^24", diff --git a/scripts/account-kinds.ts b/scripts/account-kinds.ts new file mode 100644 index 0000000..732a89f --- /dev/null +++ b/scripts/account-kinds.ts @@ -0,0 +1,87 @@ +// What scripts/add-account.ts and scripts/remove-account.ts share: the two kinds of extra account, where their +// folders and commands live, and a `which`. +import { accessSync, constants, statSync } from "node:fs" +import { homedir } from "node:os" +import { delimiter, dirname, join } from "node:path" + +export const KINDS = { + claude: { + name: "Claude", + prefix: ".claude", + envVar: "CLAUDE_CONFIG_DIR", + command: "claude", + login: [] as string[], + hint: "Sign in with the other Claude account, then type /exit.", + }, + codex: { + name: "Codex", + prefix: ".codex", + envVar: "CODEX_HOME", + command: "codex", + login: ["login"], + hint: "Sign in with the other ChatGPT account in the browser.", + }, +} as const +export type Kind = keyof typeof KINDS + +export const isKind = (s: string | undefined): s is Kind => s === "claude" || s === "codex" + +/** The main account's folder: what the env var points at (it can be named like an extra one), else ~/.claude or ~/.codex. */ +export const mainDir = (kind: Kind) => process.env[KINDS[kind].envVar] || join(homedir(), KINDS[kind].prefix) + +export const isWin = process.platform === "win32" +export const pathDirs = () => (process.env.PATH ?? "").split(delimiter).filter(Boolean) + +export function isFile(p: string) { + try { + return statSync(p).isFile() + } catch { + return false + } +} + +export function canWrite(dir: string) { + try { + accessSync(dir, constants.W_OK) + return true + } catch { + return false + } +} + +function isExecutable(p: string) { + try { + accessSync(p, constants.X_OK) + return true + } catch { + return false + } +} + +/** Full path of a command on PATH, like `which`. */ +export function which(name: string): string | undefined { + const exts = isWin ? ["", ...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")] : [""] + for (const dir of pathDirs()) { + for (const ext of exts) { + const p = join(dir, name + ext) + if (!isFile(p)) continue + if (isWin ? ext !== "" : isExecutable(p)) return p + } + } +} + +/** "claude2" -> ~/.claude-2, "claude-work" -> ~/.claude-work, "work" -> ~/.claude-work. */ +export function accountDir(kind: Kind, name: string): string | undefined { + const k = KINDS[kind] + let suffix = name.toLowerCase().startsWith(k.command) ? name.slice(k.command.length) : name + suffix = suffix.replace(/^[-_]+/, "") + return suffix ? join(homedir(), `${k.prefix}-${suffix}`) : undefined +} + +/** Where a new command goes: next to the real one on Windows, ~/.local/bin (when on PATH) elsewhere. */ +export function shimDir(real: string): string { + if (isWin) return dirname(real) + const local = join(homedir(), ".local", "bin") + if (pathDirs().includes(local)) return local + return canWrite(dirname(real)) ? dirname(real) : local +} diff --git a/scripts/add-account.ts b/scripts/add-account.ts index 4081450..f0fd3fb 100644 --- a/scripts/add-account.ts +++ b/scripts/add-account.ts @@ -1,81 +1,13 @@ // Adds another Claude or Codex account: its own config folder in home, a command that opens it // (e.g. "claude2") and a sign-in. Works on Windows, macOS and Linux; the widgets' "+" button runs it too. // Usage: npm run add-account -- [command-name] +// Removing one again: scripts/remove-account.ts. import { spawn } from "node:child_process" -import { accessSync, chmodSync, constants, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs" -import { homedir } from "node:os" -import { delimiter, dirname, join } from "node:path" +import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" import { createInterface } from "node:readline/promises" import { PROVIDERS } from "../src/providers/index.ts" - -const KINDS = { - claude: { - name: "Claude", - prefix: ".claude", - envVar: "CLAUDE_CONFIG_DIR", - command: "claude", - login: [] as string[], - hint: "Sign in with the other Claude account, then type /exit.", - }, - codex: { - name: "Codex", - prefix: ".codex", - envVar: "CODEX_HOME", - command: "codex", - login: ["login"], - hint: "Sign in with the other ChatGPT account in the browser.", - }, -} as const -type Kind = keyof typeof KINDS - -const isWin = process.platform === "win32" -const pathDirs = () => (process.env.PATH ?? "").split(delimiter).filter(Boolean) - -function isFile(p: string) { - try { - return statSync(p).isFile() - } catch { - return false - } -} - -function canWrite(dir: string) { - try { - accessSync(dir, constants.W_OK) - return true - } catch { - return false - } -} - -/** Full path of a command on PATH, like `which`. */ -function which(name: string): string | undefined { - const exts = isWin ? ["", ...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")] : [""] - for (const dir of pathDirs()) { - for (const ext of exts) { - const p = join(dir, name + ext) - if (!isFile(p)) continue - if (isWin ? ext !== "" : isExecutable(p)) return p - } - } -} - -function isExecutable(p: string) { - try { - accessSync(p, constants.X_OK) - return true - } catch { - return false - } -} - -/** "claude2" -> ~/.claude-2, "claude-work" -> ~/.claude-work, "work" -> ~/.claude-work. */ -export function accountDir(kind: Kind, name: string): string | undefined { - const k = KINDS[kind] - let suffix = name.toLowerCase().startsWith(k.command) ? name.slice(k.command.length) : name - suffix = suffix.replace(/^[-_]+/, "") - return suffix ? join(homedir(), `${k.prefix}-${suffix}`) : undefined -} +import { accountDir, isWin, type Kind, KINDS, pathDirs, shimDir, which } from "./account-kinds.ts" /** Why a command name can't be used, or undefined when it can. */ export function problem(kind: Kind, name: string): string | undefined { @@ -94,14 +26,6 @@ export function suggestion(kind: Kind): string { return `${KINDS[kind].command}${n}` } -/** Where the new command goes: next to the real one on Windows, ~/.local/bin (when on PATH) elsewhere. */ -function shimDir(real: string): string { - if (isWin) return dirname(real) - const local = join(homedir(), ".local", "bin") - if (pathDirs().includes(local)) return local - return canWrite(dirname(real)) ? dirname(real) : local -} - function writeShim(kind: Kind, file: string, dir: string) { const k = KINDS[kind] mkdirSync(dirname(file), { recursive: true }) diff --git a/scripts/remove-account.ts b/scripts/remove-account.ts new file mode 100644 index 0000000..22fe4f4 --- /dev/null +++ b/scripts/remove-account.ts @@ -0,0 +1,151 @@ +// Removes an extra Claude or Codex account added with scripts/add-account.ts: its config folder in home +// (with its sign-in) and the command that opens it (e.g. "claude2"). The main account is never touched. +// Usage: npm run remove-account -- e.g. claude claude2, codex .codex-work +// --list the extra accounts as JSON, for the widgets: [{ kind, folder, account, commands }] +// --json with a removal: prints { removed, commands, error } instead of text. +import { readdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs" +import { homedir } from "node:os" +import { basename, dirname, extname, join, resolve } from "node:path" +import { PROVIDERS } from "../src/providers/index.ts" +import { accountDir, isKind, isWin, type Kind, KINDS, mainDir, pathDirs, which } from "./account-kinds.ts" + +const same = (a: string, b: string) => (isWin ? resolve(a).toLowerCase() === resolve(b).toLowerCase() : resolve(a) === resolve(b)) + +/** The folder a command written by add-account.ts points at, or undefined when the file is something else. */ +function shimTarget(kind: Kind, file: string): string | undefined { + try { + if (statSync(file).size > 1024) return undefined + const text = readFileSync(file, "utf8") + const v = KINDS[kind].envVar + const m = isWin + ? new RegExp(`^set "${v}=(.+)"\\r?$`, "m").exec(text) + : new RegExp(`^${v}='((?:[^']|'\\\\'')*)' exec `, "m").exec(text) + return m ? (isWin ? m[1] : m[1]!.replace(/'\\''/g, "'")) : undefined + } catch { + return undefined + } +} + +/** Every command that opens `dir`: looked for next to the real command and everywhere on PATH. */ +function commandsFor(kind: Kind, dir: string): string[] { + const real = which(KINDS[kind].command) + const dirs = [...(real ? [dirname(real)] : []), join(homedir(), ".local", "bin"), ...pathDirs()] + const seen = new Set() + const found: string[] = [] + for (const d of dirs) { + const key = isWin ? resolve(d).toLowerCase() : resolve(d) + if (seen.has(key)) continue + seen.add(key) + let names: string[] + try { + names = readdirSync(d) + } catch { + continue + } + for (const n of names) { + if (isWin && extname(n).toLowerCase() !== ".cmd") continue + const file = join(d, n) + const target = shimTarget(kind, file) + if (target && same(target, dir)) found.push(file) + } + } + return found +} + +const commandName = (file: string) => basename(file, isWin ? extname(file) : "") + +/** The extra account folders the widgets show, with their account and commands. */ +async function list() { + const out: { kind: Kind; folder: string; account?: string; commands: string[] }[] = [] + for (const kind of Object.keys(KINDS) as Kind[]) { + const p = PROVIDERS[kind] + for (const dir of (await p.profiles?.().catch(() => [])) ?? []) { + const account = await p.account?.(dir).catch(() => undefined) + const commands = commandsFor(kind, dir).map(commandName) + out.push({ kind, folder: basename(dir), ...(account ? { account } : {}), commands: [...new Set(commands)] }) + } + } + return out +} + +/** + * "claude2", "claude-work", ".claude-2" or a full path -> the account folder in home; undefined when it isn't one. + * Any extra folder the widgets show counts (also ones made by hand, like ".claude2"), and so does a leftover + * ".claude-" from add-account.ts; never the main account's folder. + */ +async function resolveDir(kind: Kind, arg: string): Promise { + const k = KINDS[kind] + const home = homedir() + const profiles = (await PROVIDERS[kind].profiles?.().catch(() => [])) ?? [] + const asPath = arg.startsWith(".") || /[\\/]/.test(arg) ? resolve(home, arg) : undefined + const dir = asPath ?? commandTarget(kind, arg) ?? accountDir(kind, arg) + if (!dir || !same(dirname(dir), home) || same(dir, mainDir(kind))) return undefined + if (profiles.some((p) => same(p, dir))) return dir + const name = basename(dir).toLowerCase() + return name.startsWith(`${k.prefix}-`) && name.length > k.prefix.length + 1 ? dir : undefined +} + +/** The folder an existing command (e.g. "claude2") opens, whatever its folder is called. */ +function commandTarget(kind: Kind, name: string): string | undefined { + const file = which(name) + return file ? shimTarget(kind, file) : undefined +} + +async function remove(kind: Kind, arg: string): Promise<{ removed?: string; commands: string[]; error?: string }> { + const k = KINDS[kind] + const dir = await resolveDir(kind, arg) + if (!dir) return { commands: [], error: `${arg} is not an extra ${k.name} account.` } + const shims = commandsFor(kind, dir) + let exists = true + try { + exists = statSync(dir).isDirectory() + } catch { + exists = false + } + if (!exists && !shims.length) return { commands: [], error: `There is no ${k.name} account in ~/${basename(dir)}.` } + + if (exists) { + // Moving the folder aside first fails while that account is still open, before anything is deleted. + const aside = join(homedir(), `.openusage-removing-${basename(dir)}-${Date.now()}`) + try { + renameSync(dir, aside) + } catch { + const cmd = shims.length ? commandName(shims[0]!) : k.command + return { commands: [], error: `~/${basename(dir)} is in use. Close ${cmd} and try again.` } + } + try { + rmSync(aside, { recursive: true, force: true, maxRetries: 3 }) + } catch { + // Already out of the providers' sight; whatever is left is just a stray folder. + } + } + const removed: string[] = [] + for (const s of shims) { + try { + rmSync(s, { force: true }) + removed.push(commandName(s)) + } catch {} + } + return { removed: basename(dir), commands: removed } +} + +async function main() { + const args = process.argv.slice(2) + if (args[0] === "--list") { + process.stdout.write(JSON.stringify(await list())) + return 0 + } + const json = args.includes("--json") + const [kindArg, name] = args.filter((a) => a !== "--json") + if (!isKind(kindArg) || !name) { + console.error("Usage: npm run remove-account -- ") + return 2 + } + const r = await remove(kindArg, name.trim()) + if (json) process.stdout.write(JSON.stringify(r)) + else if (r.error) console.error(r.error) + else console.log(`Removed ~/${r.removed}${r.commands.length ? ` and the command ${r.commands.join(", ")}` : ""}.`) + return r.error ? 1 : 0 +} + +process.exitCode = await main() diff --git a/widget/usage-widget.ps1 b/widget/usage-widget.ps1 index 06cedd5..2f501ce 100644 --- a/widget/usage-widget.ps1 +++ b/widget/usage-widget.ps1 @@ -119,7 +119,7 @@ function Brush([string]$hex) { [System.Windows.Media.BrushConverter]::new().Conv Margin="0,0,-4,0" Visibility="Collapsed"/> - + @@ -1281,6 +1281,55 @@ function New-DialogXaml([string]$title, [string]$heading, [string]$body, [int]$w + + +