Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ jobs:
- run: test -x "$RUNNER_TEMP/OpenUsage.app/Contents/MacOS/OpenUsage"
# Every provider should come back as JSON, even if only with a "not signed in" error.
- run: |
node --experimental-strip-types --no-warnings scripts/usage-json.ts | node -e "const a=JSON.parse(require('fs').readFileSync(0,'utf8'));if(a.length!==4)process.exit(1);console.log(a.map(u=>u.id+': '+(u.error??'ok')).join('\n'))"
ids=$(node --experimental-strip-types --no-warnings -e "import('./src/providers/index.ts').then((m) => console.log(Object.keys(m.PROVIDERS).join(' ')))")
node --experimental-strip-types --no-warnings scripts/usage-json.ts $ids | IDS="$ids" node -e "const a=JSON.parse(require('fs').readFileSync(0,'utf8'));console.log(a.map(u=>u.id+': '+(u.error??'ok')).join('\n'));if(a.map(u=>u.id).join(' ')!==process.env.IDS)process.exit(1)"

# scripts/add-account.ts end to end on Unix, with a fake codex that "signs in" by copying an auth.json.
add-account:
Expand Down Expand Up @@ -58,3 +59,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)" = "[]"
7 changes: 5 additions & 2 deletions Installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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`.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions scripts/account-kinds.ts
Original file line number Diff line number Diff line change
@@ -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
}
84 changes: 4 additions & 80 deletions scripts/add-account.ts
Original file line number Diff line number Diff line change
@@ -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 -- <claude|codex> [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 {
Expand All @@ -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 })
Expand Down
Loading
Loading