diff --git a/Installation.md b/Installation.md index b566fc4..9e48003 100644 --- a/Installation.md +++ b/Installation.md @@ -286,7 +286,7 @@ The widget only **reads** credentials the tools themselves already saved on your | Claude | `~/.claude/.credentials.json` (or `CLAUDE_CONFIG_DIR`); on macOS, the Keychain item Claude Code saves | Open Claude Code and sign in. Token expired: open Claude Code once to renew it. | | Codex | `~/.codex/auth.json` (or `CODEX_HOME`); offline, falls back to the latest session log | Sign in to Codex with your ChatGPT account. API-key logins have no plan limits. | | Copilot | The sign-in of the Copilot editor plugins (`apps.json` in `%LOCALAPPDATA%\github-copilot` or `~/.config/github-copilot`), else the GitHub CLI (`gh auth token`) | Run `gh auth login`. The monthly premium requests are shown; on the Free plan, chat and completions. | -| Cursor | `%APPDATA%\Cursor\User\globalStorage\state.vscdb`, `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` on macOS, or `~/.config/Cursor/User/globalStorage/state.vscdb` on Linux | Open Cursor and sign in. | +| Cursor | The editor sign-in in `state.vscdb` (`%APPDATA%\Cursor\User\globalStorage` on Windows, `~/Library/Application Support/Cursor/User/globalStorage` on macOS, `~/.config/Cursor/User/globalStorage` on Linux). On Windows a sealed editor token is unlocked locally. The Cursor CLI / cursor-agent session (`%APPDATA%\Cursor\auth.json`, `~/.cursor/auth.json` on macOS, `~/.config/cursor/auth.json` on Linux) is used only when it is the same account and the editor token cannot be read | Open Cursor and sign in. If that token cannot be read, run `agent login` with the same account. | | Devin | The key saved by `devin auth login` (`~/.local/share/devin/credentials.toml`), else the Devin app's sign-in (`Devin/User/globalStorage/state.vscdb` in the app data folder) | Run `devin auth login`, or sign in to the Devin app. Shows the daily and weekly quota and the extra balance. | | Grok | The login saved by `grok login` (`~/.grok/auth.json`, or `GROK_HOME`); an expired token is renewed and written back there, like the CLI does | Run `grok login`. Shows the weekly pool and the pay-as-you-go cap. Team logins have no personal quota. | | Kilo | The Kilo account saved by `kilo auth login` (`~/.local/share/kilo/auth.json`) | Run `kilo auth login` with your Kilo account. Shows the credit balance and, with Kilo Pass, the month's usage. | diff --git a/package-lock.json b/package-lock.json index 4349928..679d709 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openusage", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openusage", - "version": "1.0.1", + "version": "1.0.2", "license": "Apache-2.0", "devDependencies": { "@types/node": "^24", diff --git a/package.json b/package.json index 181de71..528dc09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openusage", - "version": "1.0.1", + "version": "1.0.2", "description": "Floating Windows, macOS and Linux widget: usage limits of AI coding tools (Claude, Codex, Copilot, Cursor, Grok, Devin, OpenRouter, Z.ai, Ollama...)", "type": "module", "private": true, diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index 79405ae..b83de7c 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -1,4 +1,7 @@ +import { spawn } from "node:child_process" +import { createDecipheriv } from "node:crypto" import { existsSync } from "node:fs" +import { readFile } from "node:fs/promises" import { homedir } from "node:os" import { join } from "node:path" import { @@ -14,11 +17,21 @@ import { type UsageWindow, } from "./types.ts" +function userDataDir() { + if (process.platform === "win32") return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming") + if (process.platform === "darwin") return join(homedir(), "Library", "Application Support") + return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config") +} + function dbPath() { - const rel = ["Cursor", "User", "globalStorage", "state.vscdb"] - if (process.platform === "win32") return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), ...rel) - if (process.platform === "darwin") return join(homedir(), "Library", "Application Support", ...rel) - return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), ...rel) + return join(userDataDir(), "Cursor", "User", "globalStorage", "state.vscdb") +} + +/** Plaintext session saved by the Cursor CLI and cursor-agent. The editor keeps its own copy in state.vscdb. */ +function agentAuthPath() { + if (process.platform === "win32") return join(userDataDir(), "Cursor", "auth.json") + if (process.platform === "darwin") return join(homedir(), ".cursor", "auth.json") + return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cursor", "auth.json") } /** Reads one value from Cursor's VS Code state DB. */ @@ -36,64 +49,234 @@ function jwtSubject(jwt: string): string | undefined { return sub.includes("|") ? sub.split("|").pop() : sub || undefined } +const SAFE_PREFIX = "cursor-safe-storage:v1:" + +function run(command: string, args: string[], input?: string, timeout = 15_000): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }) + const out: Buffer[] = [] + const err: Buffer[] = [] + let settled = false + let timer: ReturnType + const finish = (fn: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + fn() + } + timer = setTimeout(() => { + child.kill() + finish(() => reject(new Error(`${command} timed out`))) + }, timeout) + child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) + child.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) + child.on("error", (error) => finish(() => reject(error))) + child.on("close", (code) => + finish(() => + code === 0 + ? resolve(Buffer.concat(out).toString("utf8")) + : reject(new Error(Buffer.concat(err).toString("utf8").trim() || `${command} exited ${code}`)), + ), + ) + child.stdin?.end(input ?? "") + }) +} + +/** Chromium OSCrypt key from Cursor's Local State. Current-user DPAPI, then AES-256-GCM. */ +let windowsKey: Promise | undefined +function windowsAesKey(): Promise { + windowsKey ??= loadWindowsAesKey().catch((error) => { + windowsKey = undefined + throw error + }) + return windowsKey +} + +async function loadWindowsAesKey(): Promise { + const local = JSON.parse(await readFile(join(userDataDir(), "Cursor", "Local State"), "utf8")) + const raw = Buffer.from(String(local?.os_crypt?.encrypted_key ?? ""), "base64") + if (raw.subarray(0, 5).toString("latin1") !== "DPAPI") throw new Error("no DPAPI key") + const ps = ` +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Security +$bytes = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()) +$plain = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser) +[Console]::Out.Write([Convert]::ToBase64String($plain)) +` + const out = (await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], raw.subarray(5).toString("base64"))) + .replace(/^\uFEFF/, "") + .trim() + const key = Buffer.from(out, "base64") + if (key.length !== 32) throw new Error("bad key") + return key +} + +function decryptGcm(blob: Buffer, key: Buffer): string { + if (blob.subarray(0, 3).toString("latin1") !== "v10") throw new Error("envelope") + const iv = blob.subarray(3, 15) + const tag = blob.subarray(blob.length - 16) + const data = blob.subarray(15, blob.length - 16) + const decipher = createDecipheriv("aes-256-gcm", key, iv) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8") +} + +/** + * Cursor 3.x on Windows seals the editor access token as `cursor-safe-storage:v1:` + * plus an Electron safeStorage blob. A plaintext JWT is returned untouched. + * macOS and Linux stay on that plaintext path: unlocking there would prompt the + * Keychain or a secret service on every refresh. + */ +async function unlockToken(stored: string): Promise { + if (!stored.startsWith(SAFE_PREFIX)) return stored + if (process.platform !== "win32") return undefined + try { + const blob = Buffer.from(stored.slice(SAFE_PREFIX.length), "base64") + return decryptGcm(blob, await windowsAesKey()) + } catch { + return undefined + } +} + +async function agentToken(): Promise { + const text = await readFile(agentAuthPath(), "utf8").catch(() => undefined) + if (!text) return undefined + try { + const token = JSON.parse(text)?.accessToken + return typeof token === "string" && token ? token : undefined + } catch { + return undefined + } +} + +function usable(token: string | undefined): token is string { + return !!token && !!jwtSubject(token) +} + +/** User id from a session JWT or from `google-oauth2|user_…` / `auth0|user_…`. */ +function userId(value: string | undefined): string | undefined { + if (!value) return undefined + const subject = jwtSubject(value) + if (subject) return subject + const id = value.includes("|") ? value.split("|").pop() : value + return id || undefined +} + +/** CLI session for the editor account. A different login must not replace the editor card. */ +async function sameAccountAgent(editorUser: string | undefined): Promise { + if (!editorUser) return undefined + const agent = await agentToken() + return usable(agent) && userId(agent) === editorUser ? agent : undefined +} + const dollars = (cents: number) => `$${(cents / 100).toFixed(2)}` +async function usageSummary(token: string, signal?: AbortSignal) { + const user = jwtSubject(token) + if (!user) throw new ProviderError("unrecognized Cursor token") + return getJson( + "https://cursor.com/api/usage-summary", + { + signal, + headers: { + Cookie: `WorkosCursorSessionToken=${user}%3A%3A${token}`, + Accept: "application/json", + Origin: "https://cursor.com", + Referer: "https://cursor.com/dashboard", + }, + }, + "Cursor", + ) +} + +function toUsage(body: any): ProviderUsage { + const windows: UsageWindow[] = [] + const extras: UsageExtra[] = [] + const resetsAt = toMillis(body?.billingCycleEnd) + const plan = body?.individualUsage?.plan + if (plan?.enabled !== false && plan) { + const pct = + typeof plan.totalPercentUsed === "number" + ? plan.totalPercentUsed + : plan.limit + ? (plan.used / plan.limit) * 100 + : 0 + windows.push({ label: "Billing Cycle", usedPct: clampPct(pct), resetsAt }) + // totalPercentUsed is measured against plan + bonus, so show the combined spend. + const b = plan.breakdown + if (typeof b?.total === "number" && b.bonus > 0) + extras.push({ label: "Used", value: `${dollars(b.total)} (${dollars(b.bonus)} bonus)` }) + else if (typeof plan.used === "number" && typeof plan.limit === "number" && plan.limit > 0) + extras.push({ label: "Included", value: `${dollars(plan.used)} / ${dollars(plan.limit)}` }) + } + const onDemand = body?.individualUsage?.onDemand + if (onDemand?.enabled && typeof onDemand.used === "number") { + const value = onDemand.limit ? `${dollars(onDemand.used)} / ${dollars(onDemand.limit)}` : dollars(onDemand.used) + extras.push({ label: "On-Demand", value }) + } + if (!windows.length && !extras.length) throw new ProviderError("Cursor: no usage data") + return { + id: "cursor", + name: "Cursor", + plan: body?.membershipType, + windows, + extras, + fetchedAt: Date.now(), + } +} + +async function fetchAgentOnly(signal: AbortSignal | undefined, ifMissing: ProviderError): Promise { + const agent = await agentToken() + if (!usable(agent)) throw ifMissing + return toUsage(await usageSummary(agent, signal)) +} + +/** Sealed editor token. The editor session wins; the CLI is only a same-account fallback. */ +async function fetchSealed(stored: string, signal?: AbortSignal): Promise { + const editorUser = userId(await readStateValue("cursorAuth/stripeMembershipAuthId").catch(() => undefined)) + const editor = await unlockToken(stored) + const tokens = [ + usable(editor) && (!editorUser || userId(editor) === editorUser) ? editor : undefined, + await sameAccountAgent(editorUser), + ].filter(usable) + let last: unknown + for (let i = 0; i < tokens.length; i++) { + try { + return toUsage(await usageSummary(tokens[i]!, signal)) + } catch (error) { + last = error + const rejected = error instanceof ProviderError && error.message.includes("sign in again") + if (!rejected || i === tokens.length - 1) throw error + } + } + if (last instanceof Error) throw last + throw new ProviderError("could not read the saved sign-in") +} + export const cursor: Provider = { id: "cursor", name: "Cursor", async fetch(signal) { - const token = await readStateValue("cursorAuth/accessToken") - if (!token) throw new ProviderError("not signed in to Cursor") - const user = jwtSubject(token) - if (!user) throw new ProviderError("unrecognized Cursor token") - const body = await getJson( - "https://cursor.com/api/usage-summary", - { - signal, - headers: { - Cookie: `WorkosCursorSessionToken=${user}%3A%3A${token}`, - Accept: "application/json", - Origin: "https://cursor.com", - Referer: "https://cursor.com/dashboard", - }, - }, - "Cursor", - ) - - const windows: UsageWindow[] = [] - const extras: UsageExtra[] = [] - const resetsAt = toMillis(body?.billingCycleEnd) - const plan = body?.individualUsage?.plan - if (plan?.enabled !== false && plan) { - const pct = - typeof plan.totalPercentUsed === "number" - ? plan.totalPercentUsed - : plan.limit - ? (plan.used / plan.limit) * 100 - : 0 - windows.push({ label: "Billing Cycle", usedPct: clampPct(pct), resetsAt }) - // totalPercentUsed is measured against plan + bonus, so show the combined spend. - const b = plan.breakdown - if (typeof b?.total === "number" && b.bonus > 0) - extras.push({ label: "Used", value: `${dollars(b.total)} (${dollars(b.bonus)} bonus)` }) - else if (typeof plan.used === "number" && typeof plan.limit === "number" && plan.limit > 0) - extras.push({ label: "Included", value: `${dollars(plan.used)} / ${dollars(plan.limit)}` }) + let stored: string | undefined + try { + stored = await readStateValue("cursorAuth/accessToken") + } catch (error) { + // Only a missing database may fall through to the CLI. A locked file or a + // rejected editor session must surface as itself, not as another login. + if (error instanceof ProviderError && error.message === "Cursor is not installed") { + return fetchAgentOnly(signal, error) + } + throw error } - const onDemand = body?.individualUsage?.onDemand - if (onDemand?.enabled && typeof onDemand.used === "number") { - const value = onDemand.limit ? `${dollars(onDemand.used)} / ${dollars(onDemand.limit)}` : dollars(onDemand.used) - extras.push({ label: "On-Demand", value }) + // Plain JWT: the original path. No CLI file and no process spawn. + if (stored && !stored.startsWith(SAFE_PREFIX)) { + if (!jwtSubject(stored)) throw new ProviderError("unrecognized Cursor token") + return toUsage(await usageSummary(stored, signal)) } - if (!windows.length && !extras.length) throw new ProviderError("Cursor: no usage data") - return { - id: "cursor", - name: "Cursor", - plan: body?.membershipType, - windows, - extras, - fetchedAt: Date.now(), - } satisfies ProviderUsage + if (stored?.startsWith(SAFE_PREFIX)) return fetchSealed(stored, signal) + // Editor database present, but nobody is signed in. Don't borrow another login. + throw new ProviderError("not signed in to Cursor") }, account: () => readStateValue("cursorAuth/cachedEmail"), - detect: async () => existsSync(dbPath()), + detect: async () => existsSync(dbPath()) || usable(await agentToken()), } diff --git a/widget-mac/build.sh b/widget-mac/build.sh index 8a35a23..6b87521 100755 --- a/widget-mac/build.sh +++ b/widget-mac/build.sh @@ -47,8 +47,8 @@ cat > "$CONTENTS/Info.plist" <<'PLIST' CFBundleExecutableOpenUsage CFBundleIconFileOpenUsage CFBundlePackageTypeAPPL - CFBundleShortVersionString1.0.1 - CFBundleVersion1 + CFBundleShortVersionString1.0.2 + CFBundleVersion2 LSMinimumSystemVersion12.0 LSUIElement NSHighResolutionCapable