diff --git a/bun.lockb b/bun.lockb index b57c205..785bab3 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index d5b235d..ccffb7b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "lint": "eslint .", "format": "prettier --write .", "bundle": "bun scripts/build-npm.ts", + "build:menubar": "bun scripts/build-menubar-app.ts", + "package:menubar": "bun scripts/package-menubar-app.ts", "prepublish": "bun run build && bun run bundle && bun scripts/prepare-publish.ts", "publish:npm": "bun run prepublish && cd dist && npm publish" }, diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 63ca4b2..5fc9bc8 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -695,11 +695,21 @@ describe('run', () => { }); test('throws a login hint when cursor is requested without auth or cache', async () => { + const emptyRoot = mkdtempSync(join(tmpdir(), 'tokenleak-cli-empty-cursor-')); + const previousEnv = process.env; + process.env = { + ...process.env, + TOKENLEAK_CURSOR_DIR: emptyRoot, + }; + let thrown: unknown; try { await run({ format: 'json', provider: 'cursor' }); } catch (error: unknown) { thrown = error; + } finally { + process.env = previousEnv; + rmSync(emptyRoot, { recursive: true, force: true }); } expect(thrown).toBeInstanceOf(TokenleakError); expect((thrown as TokenleakError).message).toBe( diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4629e94..4f92c65 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -48,6 +48,7 @@ import { shouldStartInteractiveCli, startInteractiveCli } from './interactive.js import { copyToClipboard, openFile, uploadToGist } from './sharing/index.js'; import { startTabbedDashboard } from './tabbed-dashboard.js'; import type { TabbedDashboardOptions } from './tabbed-dashboard.js'; +import { buildMenubarHelpText, runMenubarCommand } from './menubar/command.js'; export { computeDateRange }; export { renderFocusReport, colorScore, colorDuration, colorDensity, colorProvider, colorStreak }; @@ -159,12 +160,14 @@ function buildHelpText(): string { ' tokenleak focus [flags]', ' tokenleak replay [date] [flags]', ' tokenleak cursor ', + ' tokenleak menubar ', '', 'Subcommands:', ' explain Explain what drove usage on one day', ' focus Rank sessions by deep-work score', ' replay [date] Replay a day\'s session timeline (defaults to today)', ' cursor Manage Cursor auth and cache sync', + ' menubar Install and manage the macOS quota menubar app', '', 'Provider Shortcuts:', ' --claude Only include Claude Code', @@ -2140,6 +2143,24 @@ if (isDirectExecution) { handleError(error); } } + if (argv[0] === 'menubar') { + try { + if (argv[1] === '--help' || argv[1] === '-h' || argv.length === 1) { + process.stdout.write(buildMenubarHelpText()); + process.exit(0); + } + + if (argv[1] === '--version' || argv[1] === '-v') { + process.stdout.write(buildVersionText()); + process.exit(0); + } + + await runMenubarCommand(argv.slice(1), process.argv[1]!); + process.exit(0); + } catch (error: unknown) { + handleError(error); + } + } process.argv = [...process.argv.slice(0, 2), ...normalizedArgv]; if (argv.includes('--help') || argv.includes('-h')) { diff --git a/packages/cli/src/menubar/claude-statusline.ts b/packages/cli/src/menubar/claude-statusline.ts new file mode 100644 index 0000000..d0ef21b --- /dev/null +++ b/packages/cli/src/menubar/claude-statusline.ts @@ -0,0 +1,152 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import type { MenubarPaths } from './types.js'; +import { MENUBAR_SCHEMA_VERSION, type ClaudeBridgeSnapshot, type StoredQuotaWindow } from './types.js'; +import { writeClaudeBridgeSnapshot } from './state.js'; + +function toResetAtIso(value: unknown): string | null { + if (typeof value === 'string' && value.length > 0) { + return value; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return new Date(value * 1000).toISOString(); + } + return null; +} + +function parseWindow(value: unknown, fallbackMinutes: number): StoredQuotaWindow | null { + if (typeof value !== 'object' || value === null) { + return null; + } + + const record = value as Record; + const usedPercent = record['used_percentage'] ?? record['usedPercent'] ?? record['used_percent']; + const resetAt = record['resets_at'] ?? record['resetAt'] ?? record['reset_at']; + const windowMinutes = record['window_minutes'] ?? record['windowMinutes'] ?? fallbackMinutes; + + if (typeof usedPercent !== 'number') { + return null; + } + + return { + usedPercent, + windowMinutes: typeof windowMinutes === 'number' ? windowMinutes : fallbackMinutes, + resetAt: toResetAtIso(resetAt), + }; +} + +function resolvePlanType(record: Record): string | null { + const candidates = [ + record['subscription_type'], + record['subscriptionType'], + record['plan_type'], + typeof record['account'] === 'object' && record['account'] !== null + ? (record['account'] as Record)['subscription_type'] + : null, + ]; + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate; + } + } + + return null; +} + +async function readStdinText(): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of process.stdin) { + if (typeof chunk === 'string') { + chunks.push(Buffer.from(chunk)); + } else { + chunks.push(Buffer.from(chunk)); + } + } + + return Buffer.concat(chunks).toString('utf8'); +} + +function debugLog(paths: MenubarPaths, message: string): void { + try { + const logPath = join(paths.logsDir, 'claude-statusline-debug.log'); + mkdirSync(dirname(logPath), { recursive: true }); + const timestamp = new Date().toISOString(); + appendFileSync(logPath, `[${timestamp}] ${message}\n`); + } catch { + // best-effort logging + } +} + +function extractRateLimits(root: Record): { + fiveHour: StoredQuotaWindow | null; + sevenDay: StoredQuotaWindow | null; +} | null { + // Try standard structure: { rate_limits: { five_hour: ..., seven_day: ... } } + const rateLimits = root['rate_limits'] ?? root['rateLimits']; + if (typeof rateLimits === 'object' && rateLimits !== null) { + const rl = rateLimits as Record; + const fiveHour = parseWindow(rl['five_hour'] ?? rl['fiveHour'], 300); + const sevenDay = parseWindow(rl['seven_day'] ?? rl['sevenDay'], 10080); + // Also try primary/secondary (Codex-style naming) + const primary = fiveHour ?? parseWindow(rl['primary'], 300); + const secondary = sevenDay ?? parseWindow(rl['secondary'], 10080); + if (primary || secondary) { + return { fiveHour: primary, sevenDay: secondary }; + } + } + + // Try top-level windows: { five_hour: ..., seven_day: ... } + const topFive = parseWindow(root['five_hour'] ?? root['fiveHour'], 300); + const topSeven = parseWindow(root['seven_day'] ?? root['sevenDay'], 10080); + if (topFive || topSeven) { + return { fiveHour: topFive, sevenDay: topSeven }; + } + + return null; +} + +export async function recordClaudeStatuslineSnapshot(paths: MenubarPaths): Promise { + const input = (await readStdinText()).trim(); + if (!input) { + debugLog(paths, 'Empty stdin — no data received'); + return false; + } + + debugLog(paths, `Received ${input.length} bytes: ${input.slice(0, 500)}`); + + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + debugLog(paths, `JSON parse error for input: ${input.slice(0, 200)}`); + return false; + } + + if (typeof parsed !== 'object' || parsed === null) { + debugLog(paths, 'Parsed value is not an object'); + return false; + } + + const root = parsed as Record; + const result = extractRateLimits(root); + + if (!result || (!result.fiveHour && !result.sevenDay)) { + debugLog(paths, `No rate limit windows found in keys: ${Object.keys(root).join(', ')}`); + return false; + } + + debugLog(paths, `Parsed OK — 5h: ${result.fiveHour?.usedPercent ?? '--'}%, 7d: ${result.sevenDay?.usedPercent ?? '--'}%`); + + const snapshot: ClaudeBridgeSnapshot = { + schemaVersion: MENUBAR_SCHEMA_VERSION, + source: 'claude-statusline', + capturedAt: new Date().toISOString(), + planType: resolvePlanType(root), + fiveHour: result.fiveHour, + sevenDay: result.sevenDay, + }; + writeClaudeBridgeSnapshot(paths, snapshot); + return true; +} diff --git a/packages/cli/src/menubar/command.ts b/packages/cli/src/menubar/command.ts new file mode 100644 index 0000000..40f4b9d --- /dev/null +++ b/packages/cli/src/menubar/command.ts @@ -0,0 +1,189 @@ +import { existsSync } from 'node:fs'; +import { TokenleakError } from '../errors.js'; +import { recordClaudeStatuslineSnapshot } from './claude-statusline.js'; +import { formatTimestamp } from './format.js'; +import { + installMenubar, + openDashboardInTerminal, + openMenubarApp, + printMenubarStatus, + startMenubarApp, + stopMenubarApp, + uninstallMenubar, +} from './install.js'; +import { resolveMenubarPaths } from './paths.js'; +import { + createDefaultMenubarConfig, + ensureClaudeStatusLineConfig, + readMenubarConfig, + refreshMenubarSnapshot, + writeMenubarConfig, +} from './state.js'; + +interface ParsedMenubarArgs { + command: string; + homeDir?: string; + pollIntervalSeconds?: number; + once: boolean; + help: boolean; +} + +function parseArgs(argv: string[]): ParsedMenubarArgs { + const parsed: ParsedMenubarArgs = { + command: argv[0] ?? 'help', + once: false, + help: false, + }; + + let index = 1; + while (index < argv.length) { + const arg = argv[index]!; + switch (arg) { + case '--help': + case '-h': + parsed.help = true; + index += 1; + break; + case '--home': + if (!argv[index + 1]) throw new TokenleakError('--home requires a value'); + parsed.homeDir = argv[index + 1]!; + index += 2; + break; + case '--poll': + if (!argv[index + 1]) throw new TokenleakError('--poll requires a value'); + parsed.pollIntervalSeconds = Number(argv[index + 1]!); + index += 2; + break; + case '--once': + parsed.once = true; + index += 1; + break; + default: + throw new TokenleakError(`Unknown menubar flag "${arg}"`); + } + } + + return parsed; +} + +function resolveCommandConfig(parsed: ParsedMenubarArgs) { + const paths = resolveMenubarPaths(parsed.homeDir); + const config = existsSync(paths.configPath) + ? readMenubarConfig(paths) + : createDefaultMenubarConfig(); + + if (parsed.pollIntervalSeconds !== undefined) { + config.pollIntervalSeconds = Math.max(10, Math.round(parsed.pollIntervalSeconds)); + writeMenubarConfig(paths, config); + } + + return { paths, config }; +} + +export function buildMenubarHelpText(): string { + return [ + 'tokenleak menubar', + 'Install and manage the macOS quota menubar app for Codex and Claude Code.', + '', + 'Usage:', + ' tokenleak menubar install', + ' tokenleak menubar uninstall', + ' tokenleak menubar status', + ' tokenleak menubar refresh', + ' tokenleak menubar open', + ' tokenleak menubar start', + ' tokenleak menubar stop', + '', + ].join('\n'); +} + +async function runRefresh(parsed: ParsedMenubarArgs): Promise { + const { paths } = resolveCommandConfig(parsed); + const snapshot = await refreshMenubarSnapshot(paths); + process.stdout.write(`Snapshot updated: ${snapshot.title}\n`); +} + +async function runDaemon(parsed: ParsedMenubarArgs): Promise { + const { paths, config } = resolveCommandConfig(parsed); + + const tick = async () => { + // Self-healing: repair statusline settings if overwritten + ensureClaudeStatusLineConfig(paths, readMenubarConfig(paths)); + const snapshot = await refreshMenubarSnapshot(paths); + process.stdout.write(`[menubar] ${formatTimestamp(snapshot.generatedAt)} ${snapshot.title}\n`); + }; + + await tick(); + if (parsed.once) { + return; + } + + const interval = setInterval(() => { + void tick(); + }, config.pollIntervalSeconds * 1000); + + await new Promise((resolve) => { + const stop = () => { + clearInterval(interval); + resolve(); + }; + + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + }); +} + +async function runClaudeStatusline(parsed: ParsedMenubarArgs): Promise { + const paths = resolveMenubarPaths(parsed.homeDir); + await recordClaudeStatuslineSnapshot(paths); +} + +export async function runMenubarCommand(argv: string[], cliEntrypoint: string): Promise { + const parsed = parseArgs(argv); + + if (parsed.help || parsed.command === 'help') { + process.stdout.write(buildMenubarHelpText()); + return; + } + + switch (parsed.command) { + case 'install': { + const paths = await installMenubar(parsed.homeDir, cliEntrypoint); + process.stdout.write(`Installed Tokenleak Usage at ${paths.installedAppPath}\n`); + return; + } + case 'uninstall': { + const paths = uninstallMenubar(parsed.homeDir); + process.stdout.write(`Removed menubar install from ${paths.appSupportDir}\n`); + return; + } + case 'status': + printMenubarStatus(resolveMenubarPaths(parsed.homeDir)); + return; + case 'refresh': + await runRefresh(parsed); + return; + case 'open': + openMenubarApp(resolveMenubarPaths(parsed.homeDir)); + return; + case 'start': + startMenubarApp(resolveMenubarPaths(parsed.homeDir)); + process.stdout.write('Started menubar app.\n'); + return; + case 'stop': + stopMenubarApp(resolveMenubarPaths(parsed.homeDir)); + process.stdout.write('Stopped menubar app.\n'); + return; + case 'daemon': + await runDaemon(parsed); + return; + case 'claude-statusline': + await runClaudeStatusline(parsed); + return; + case 'open-dashboard': + openDashboardInTerminal(resolveMenubarPaths(parsed.homeDir)); + return; + default: + throw new TokenleakError(`Unknown menubar command "${parsed.command}"`); + } +} diff --git a/packages/cli/src/menubar/format.ts b/packages/cli/src/menubar/format.ts new file mode 100644 index 0000000..764e4de --- /dev/null +++ b/packages/cli/src/menubar/format.ts @@ -0,0 +1,29 @@ +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +export function formatTimestamp(value: string | null): string { + if (!value) { + return 'never'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString(); +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +export function toRemainingPercent(value: number | null): number | null { + return typeof value === 'number' ? clampPercent(100 - value) : null; +} + +export function formatPercentLeft(value: number | null): string { + const remaining = toRemainingPercent(value); + return typeof remaining === 'number' ? `${Math.round(remaining)}%` : '--'; +} diff --git a/packages/cli/src/menubar/install.ts b/packages/cli/src/menubar/install.ts new file mode 100644 index 0000000..dd5677a --- /dev/null +++ b/packages/cli/src/menubar/install.ts @@ -0,0 +1,339 @@ +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { TokenleakError } from '../errors.js'; +import { formatPercentLeft, formatTimestamp } from './format.js'; +import { + buildAppPlist, + buildCliWrapper, + buildClaudeStatuslineBridge, + buildDashboardWrapper, + buildOriginalClaudeStatuslineCommandScript, +} from './launchd.js'; +import { MENUBAR_APP_LABEL, resolveMenubarPaths } from './paths.js'; +import { + clearMenubarState, + createDefaultMenubarConfig, + ensureMenubarDir, + isManagedClaudeStatusLineSetting, + readMenubarConfig, + readSnapshot, + writeExecutableScript, + writeMenubarConfig, +} from './state.js'; +import { CURRENT_BRIDGE_VERSION } from './types.js'; +import type { MenubarConfig, MenubarPaths } from './types.js'; + +const LEGACY_APP_LABEL = 'com.tokenleak.menubar.app'; +const LEGACY_SERVICE_LABEL = 'com.tokenleak.menubar.service'; +const LEGACY_APP_NAME = 'Tokenleak Menu.app'; +const LEGACY_SERVICE_WRAPPER = 'tokenleak-menubar-service'; + +function runCommand(command: string[], cwd?: string, quiet: boolean = false): void { + const proc = Bun.spawnSync(command, { + cwd, + stdout: quiet ? 'ignore' : 'inherit', + stderr: quiet ? 'ignore' : 'inherit', + }); + + if (proc.exitCode !== 0) { + throw new TokenleakError(`Command failed: ${command.join(' ')}`); + } +} + +function resolveLocalMenubarBuilderPath(): string { + return resolve(import.meta.dir, '../../../../scripts/build-menubar-app.ts'); +} + +function resolveLocalBuiltAppPath(): string { + return resolve(import.meta.dir, '../../../../packages/menubar/dist', 'Tokenleak Usage.app'); +} + +function ensureInstallDirs(paths: MenubarPaths): void { + ensureMenubarDir(paths.appSupportDir); + ensureMenubarDir(paths.logsDir); + ensureMenubarDir(paths.launchAgentsDir); + ensureMenubarDir(dirname(paths.installedAppPath)); + ensureMenubarDir(dirname(paths.claudeSettingsPath)); +} + +function buildLocalApp(): string { + const buildScript = resolveLocalMenubarBuilderPath(); + if (!existsSync(buildScript)) { + throw new TokenleakError('Local menubar app builder not found.'); + } + + runCommand([process.execPath, buildScript], resolve(import.meta.dir, '../../../../')); + const appPath = resolveLocalBuiltAppPath(); + if (!existsSync(appPath)) { + throw new TokenleakError('Menubar app build did not produce an app bundle.'); + } + return appPath; +} + +function copyAppBundle(sourceAppPath: string, targetAppPath: string): void { + rmSync(targetAppPath, { recursive: true, force: true }); + cpSync(sourceAppPath, targetAppPath, { recursive: true }); +} + +function readClaudeSettings(paths: MenubarPaths): Record { + if (!existsSync(paths.claudeSettingsPath)) { + return {}; + } + + return JSON.parse(readFileSync(paths.claudeSettingsPath, 'utf8')) as Record; +} + +function writeClaudeSettings(paths: MenubarPaths, settings: Record): void { + ensureMenubarDir(dirname(paths.claudeSettingsPath)); + writeFileSync(paths.claudeSettingsPath, `${JSON.stringify(settings, null, 2)}\n`); +} + +function parseCommandStatusLine(setting: unknown): { type: 'command'; command: string } | null { + if (typeof setting !== 'object' || setting === null) { + return null; + } + + const record = setting as Record; + if (record['type'] !== 'command' || typeof record['command'] !== 'string') { + return null; + } + + return { type: 'command', command: record['command'] }; +} + +function writeInstallArtifacts( + paths: MenubarPaths, + cliEntrypoint: string, + config: MenubarConfig, +): void { + writeExecutableScript(paths.cliWrapperPath, buildCliWrapper(process.execPath, cliEntrypoint)); + writeExecutableScript(paths.dashboardWrapperPath, buildDashboardWrapper(paths)); + writeExecutableScript(paths.claudeStatuslineWrapperPath, buildClaudeStatuslineBridge(paths)); + + const previous = parseCommandStatusLine(config.claudeStatusLineBackup); + if (previous) { + writeExecutableScript( + paths.previousClaudeStatuslineCommandPath, + buildOriginalClaudeStatuslineCommandScript(previous.command), + ); + } else { + rmSync(paths.previousClaudeStatuslineCommandPath, { force: true }); + } + + writeFileSync(paths.appPlistPath, buildAppPlist(paths)); + writeMenubarConfig(paths, config); +} + +function configureClaudeStatusLine(paths: MenubarPaths, config: MenubarConfig): MenubarConfig { + const settings = readClaudeSettings(paths); + const current = settings['statusLine']; + + if (!isManagedClaudeStatusLineSetting(paths, current)) { + config.claudeStatusLineBackup = current ?? null; + } + + settings['statusLine'] = { type: 'command', command: paths.claudeStatuslineWrapperPath }; + config.claudeStatusLineManaged = true; + config.claudeBridgeVersion = CURRENT_BRIDGE_VERSION; + writeClaudeSettings(paths, settings); + return config; +} + +function restoreClaudeStatusLine(paths: MenubarPaths, config: MenubarConfig): void { + const settings = readClaudeSettings(paths); + if (!isManagedClaudeStatusLineSetting(paths, settings['statusLine'])) { + return; + } + + if (config.claudeStatusLineBackup === null) { + delete settings['statusLine']; + } else { + settings['statusLine'] = config.claudeStatusLineBackup; + } + + writeClaudeSettings(paths, settings); +} + +function guiDomain(): string { + const uid = + typeof process.getuid === 'function' ? process.getuid() : Number(process.env['UID'] ?? 0); + return `gui/${uid}`; +} + +function launchctlLabelPath(label: string): string { + return `${guiDomain()}/${label}`; +} + +function bootoutIfLoaded(label: string, plistPath: string): void { + const proc = Bun.spawnSync(['/bin/launchctl', 'bootout', launchctlLabelPath(label), plistPath], { + stdout: 'ignore', + stderr: 'ignore', + }); + + if (proc.exitCode !== 0) { + Bun.spawnSync(['/bin/launchctl', 'bootout', guiDomain(), plistPath], { + stdout: 'ignore', + stderr: 'ignore', + }); + } +} + +function killMatchingProcesses(pattern: string): void { + Bun.spawnSync(['/usr/bin/pkill', '-f', pattern], { + stdout: 'ignore', + stderr: 'ignore', + }); +} + +function cleanupLegacyMenubarInstall(paths: MenubarPaths): void { + const legacyAppPlistPath = join(paths.launchAgentsDir, `${LEGACY_APP_LABEL}.plist`); + const legacyServicePlistPath = join(paths.launchAgentsDir, `${LEGACY_SERVICE_LABEL}.plist`); + const legacyAppPath = join(dirname(paths.installedAppPath), LEGACY_APP_NAME); + const legacyServiceWrapperPath = join(paths.appSupportDir, LEGACY_SERVICE_WRAPPER); + + if (existsSync(legacyAppPlistPath)) { + bootoutIfLoaded(LEGACY_APP_LABEL, legacyAppPlistPath); + unlinkSync(legacyAppPlistPath); + } + + if (existsSync(legacyServicePlistPath)) { + bootoutIfLoaded(LEGACY_SERVICE_LABEL, legacyServicePlistPath); + unlinkSync(legacyServicePlistPath); + } + + killMatchingProcesses('/Tokenleak Menu.app/Contents/MacOS/Tokenleak Menu'); + killMatchingProcesses('tokenleak-menubar-service'); + + rmSync(legacyAppPath, { recursive: true, force: true }); + rmSync(legacyServiceWrapperPath, { force: true }); +} + +export function startMenubarApp(paths: MenubarPaths): void { + if (!existsSync(paths.appPlistPath)) { + throw new TokenleakError('Menubar is not installed. Run `tokenleak menubar install` first.'); + } + + bootoutIfLoaded(MENUBAR_APP_LABEL, paths.appPlistPath); + runCommand(['/bin/launchctl', 'bootstrap', guiDomain(), paths.appPlistPath], undefined, true); + runCommand( + ['/bin/launchctl', 'kickstart', '-k', launchctlLabelPath(MENUBAR_APP_LABEL)], + undefined, + true, + ); +} + +export function stopMenubarApp(paths: MenubarPaths): void { + if (existsSync(paths.appPlistPath)) { + bootoutIfLoaded(MENUBAR_APP_LABEL, paths.appPlistPath); + } +} + +export function openMenubarApp(paths: MenubarPaths): void { + if (!existsSync(paths.installedAppPath)) { + throw new TokenleakError('Menubar is not installed. Run `tokenleak menubar install` first.'); + } + + runCommand(['/usr/bin/open', paths.installedAppPath], undefined, true); +} + +export function openDashboardInTerminal(paths: MenubarPaths): void { + if (!existsSync(paths.dashboardWrapperPath)) { + throw new TokenleakError('Dashboard wrapper missing. Reinstall the menubar.'); + } + + runCommand(['/usr/bin/open', '-a', 'Terminal', paths.dashboardWrapperPath], undefined, true); +} + +function launchctlState(label: string): 'loaded' | 'stopped' { + const proc = Bun.spawnSync(['/bin/launchctl', 'print', launchctlLabelPath(label)], { + stdout: 'ignore', + stderr: 'ignore', + }); + return proc.exitCode === 0 ? 'loaded' : 'stopped'; +} + +function printStateLine(label: string, value: string): void { + process.stdout.write(`${label}: ${value}\n`); +} + +export function printMenubarStatus(paths: MenubarPaths): void { + const config = existsSync(paths.configPath) ? readMenubarConfig(paths) : createDefaultMenubarConfig(); + const snapshot = readSnapshot(paths); + const claudeSettings = readClaudeSettings(paths); + + printStateLine('installed_app', existsSync(paths.installedAppPath) ? 'yes' : 'no'); + printStateLine( + 'app_agent', + existsSync(paths.appPlistPath) ? launchctlState(MENUBAR_APP_LABEL) : 'missing', + ); + printStateLine( + 'claude_statusline', + isManagedClaudeStatusLineSetting(paths, claudeSettings['statusLine']) ? 'managed' : 'other', + ); + printStateLine('poll_interval_seconds', String(config.pollIntervalSeconds)); + + if (!snapshot) { + printStateLine('snapshot', 'missing'); + return; + } + + printStateLine('snapshot', 'present'); + printStateLine('title', snapshot.title); + printStateLine('generated_at', formatTimestamp(snapshot.generatedAt)); + printStateLine('codex_state', snapshot.providers.codex.state); + printStateLine( + 'codex_5h_left', + formatPercentLeft(snapshot.providers.codex.windows.fiveHour.usedPercent), + ); + if (snapshot.providers.codex.message) { + printStateLine('codex_message', snapshot.providers.codex.message); + } + printStateLine('claude_state', snapshot.providers.claudeCode.state); + printStateLine( + 'claude_5h_left', + formatPercentLeft(snapshot.providers.claudeCode.windows.fiveHour.usedPercent), + ); + if (snapshot.providers.claudeCode.message) { + printStateLine('claude_message', snapshot.providers.claudeCode.message); + } +} + +export async function installMenubar( + homeDir: string | undefined, + cliEntrypoint: string, +): Promise { + const paths = resolveMenubarPaths(homeDir); + ensureInstallDirs(paths); + cleanupLegacyMenubarInstall(paths); + + let config = existsSync(paths.configPath) ? readMenubarConfig(paths) : createDefaultMenubarConfig(); + config = configureClaudeStatusLine(paths, config); + + const appSource = buildLocalApp(); + copyAppBundle(appSource, paths.installedAppPath); + writeInstallArtifacts(paths, cliEntrypoint, config); + startMenubarApp(paths); + return paths; +} + +export function uninstallMenubar(homeDir: string | undefined): MenubarPaths { + const paths = resolveMenubarPaths(homeDir); + const config = existsSync(paths.configPath) ? readMenubarConfig(paths) : createDefaultMenubarConfig(); + + stopMenubarApp(paths); + cleanupLegacyMenubarInstall(paths); + restoreClaudeStatusLine(paths, config); + if (existsSync(paths.appPlistPath)) unlinkSync(paths.appPlistPath); + rmSync(paths.installedAppPath, { recursive: true, force: true }); + clearMenubarState(paths); + rmSync(paths.appSupportDir, { recursive: true, force: true }); + return paths; +} diff --git a/packages/cli/src/menubar/launchd.test.ts b/packages/cli/src/menubar/launchd.test.ts new file mode 100644 index 0000000..bb53888 --- /dev/null +++ b/packages/cli/src/menubar/launchd.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'bun:test'; +import { buildAppPlist, buildClaudeStatuslineBridge, buildDashboardWrapper } from './launchd'; +import { resolveMenubarPaths } from './paths'; + +describe('menubar launchd and wrapper generation', () => { + const paths = resolveMenubarPaths('/tmp/tokenleak-home'); + + it('builds an app plist for the menubar app', () => { + const plist = buildAppPlist(paths); + expect(plist).toContain('com.tokenleak.menubar'); + expect(plist).toContain('Tokenleak Usage'); + expect(plist).toContain('TOKENLEAK_MENUBAR_HOME'); + }); + + it('builds a dashboard wrapper pinned to codex and claude-code', () => { + const wrapper = buildDashboardWrapper(paths); + expect(wrapper).toContain('--provider codex,claude-code'); + }); + + it('builds a self-contained Claude statusline bridge using python3', () => { + const bridge = buildClaudeStatuslineBridge(paths); + expect(bridge).toContain('/usr/bin/python3'); + expect(bridge).toContain('claude-rate-limits.json'); + expect(bridge).toContain('claude-statusline-original'); + // Must NOT spawn the tokenleak CLI binary + expect(bridge).not.toContain('menubar claude-statusline'); + expect(bridge).not.toContain('tokenleak-menubar-cli'); + }); + + it('bridge script extracts rate_limits fields', () => { + const bridge = buildClaudeStatuslineBridge(paths); + expect(bridge).toContain('rate_limits'); + expect(bridge).toContain('five_hour'); + expect(bridge).toContain('seven_day'); + expect(bridge).toContain('used_percentage'); + expect(bridge).toContain('resets_at'); + }); + + it('bridge script performs atomic write', () => { + const bridge = buildClaudeStatuslineBridge(paths); + expect(bridge).toContain('os.rename'); + expect(bridge).toContain('mkstemp'); + }); +}); diff --git a/packages/cli/src/menubar/launchd.ts b/packages/cli/src/menubar/launchd.ts new file mode 100644 index 0000000..ae7b463 --- /dev/null +++ b/packages/cli/src/menubar/launchd.ts @@ -0,0 +1,168 @@ +import type { MenubarPaths } from './types.js'; +import { MENUBAR_APP_LABEL } from './paths.js'; +import { shellQuote } from './format.js'; + +function xmlEscape(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function renderStringArray(values: string[]): string { + return values.map((value) => ` ${xmlEscape(value)}`).join('\n'); +} + +export function buildAppPlist(paths: MenubarPaths): string { + const executablePath = `${paths.installedAppPath}/Contents/MacOS/Tokenleak Usage`; + + return ` + + + + Label + ${MENUBAR_APP_LABEL} + ProgramArguments + +${renderStringArray([executablePath])} + + RunAtLoad + + KeepAlive + + EnvironmentVariables + + TOKENLEAK_MENUBAR_HOME + ${xmlEscape(paths.homeDir)} + + WorkingDirectory + ${xmlEscape(paths.appSupportDir)} + StandardOutPath + ${xmlEscape(paths.appLogPath)} + StandardErrorPath + ${xmlEscape(paths.appLogPath)} + + +`; +} + +export function buildCliWrapper(processExecPath: string, cliEntrypoint: string): string { + return `#!/bin/zsh +exec ${shellQuote(processExecPath)} ${shellQuote(cliEntrypoint)} "$@" +`; +} + +export function buildDashboardWrapper(paths: MenubarPaths): string { + return `#!/bin/zsh +exec ${shellQuote(paths.cliWrapperPath)} --provider codex,claude-code "$@" +`; +} + +export function buildClaudeStatuslineBridge(paths: MenubarPaths): string { + const snapshotPath = shellQuote(paths.claudeSnapshotPath); + const originalCmd = shellQuote(paths.previousClaudeStatuslineCommandPath); + + // Self-contained bridge: uses /usr/bin/python3 (pre-installed on macOS 12.3+) + // to extract rate_limits from Claude Code's statusline JSON and atomic-write + // the snapshot file. The python3 process runs in the background so the user's + // original statusline command renders with zero added latency. + return `#!/bin/zsh +set -u +SNAPSHOT_PATH=${snapshotPath} +ORIGINAL_CMD=${originalCmd} + +tmp_file=$(mktemp "\${TMPDIR:-/tmp}/tl-claude-sl.XXXXXX") +cat > "$tmp_file" + +# Background: extract rate_limits and write snapshot atomically +(/usr/bin/python3 -c ' +import json, sys, os, tempfile +from datetime import datetime, timezone + +snap_path = sys.argv[1] +input_path = sys.argv[2] + +try: + with open(input_path) as f: + data = json.load(f) +except Exception: + sys.exit(0) + +rl = data.get("rate_limits") or data.get("rateLimits") +if not isinstance(rl, dict): + sys.exit(0) + +def parse_window(w, fallback_min): + if not isinstance(w, dict): + return None + pct = w.get("used_percentage") or w.get("usedPercent") or w.get("used_percent") + if not isinstance(pct, (int, float)): + return None + reset = w.get("resets_at") or w.get("resetAt") or w.get("reset_at") + reset_iso = None + if isinstance(reset, (int, float)) and reset > 0: + reset_iso = datetime.fromtimestamp(reset, tz=timezone.utc).isoformat().replace("+00:00", "Z") + elif isinstance(reset, str) and reset: + reset_iso = reset + wm = w.get("window_minutes") or w.get("windowMinutes") or fallback_min + return {"usedPercent": pct, "windowMinutes": wm if isinstance(wm, int) else fallback_min, "resetAt": reset_iso} + +five = parse_window(rl.get("five_hour") or rl.get("fiveHour"), 300) +seven = parse_window(rl.get("seven_day") or rl.get("sevenDay"), 10080) + +if not five and not seven: + sys.exit(0) + +plan = None +for k in ("subscription_type", "subscriptionType", "plan_type"): + v = data.get(k) + if isinstance(v, str) and v.strip(): + plan = v.strip() + break +if plan is None: + acct = data.get("account") + if isinstance(acct, dict): + v = acct.get("subscription_type") + if isinstance(v, str) and v.strip(): + plan = v.strip() + +snapshot = { + "schemaVersion": 1, + "source": "claude-statusline", + "capturedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "planType": plan, + "fiveHour": five, + "sevenDay": seven, +} + +snap_dir = os.path.dirname(snap_path) +os.makedirs(snap_dir, exist_ok=True) +fd, tmp = tempfile.mkstemp(dir=snap_dir, suffix=".tmp") +try: + with os.fdopen(fd, "w") as out: + json.dump(snapshot, out, indent=2) + out.write("\\n") + os.rename(tmp, snap_path) +except Exception: + try: + os.unlink(tmp) + except Exception: + pass +' "$SNAPSHOT_PATH" "$tmp_file" +rm -f "$tmp_file") 2>/dev/null & + +# Forward to user's original statusline command immediately (no delay) +if [ -x "$ORIGINAL_CMD" ]; then + exec "$ORIGINAL_CMD" < "$tmp_file" +fi +exit 0 +`; +} + +export function buildOriginalClaudeStatuslineCommandScript(command: string): string { + return `#!/bin/zsh +${command} +`; +} diff --git a/packages/cli/src/menubar/paths.ts b/packages/cli/src/menubar/paths.ts new file mode 100644 index 0000000..f4eec31 --- /dev/null +++ b/packages/cli/src/menubar/paths.ts @@ -0,0 +1,30 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { MenubarPaths } from './types.js'; + +export const MENUBAR_APP_LABEL = 'com.tokenleak.menubar'; + +export function resolveMenubarPaths(homeDir: string = homedir()): MenubarPaths { + const appSupportDir = join(homeDir, 'Library', 'Application Support', 'tokenleak', 'menubar'); + const logsDir = join(appSupportDir, 'logs'); + const launchAgentsDir = join(homeDir, 'Library', 'LaunchAgents'); + + return { + homeDir, + appSupportDir, + logsDir, + launchAgentsDir, + configPath: join(appSupportDir, 'config.json'), + snapshotPath: join(appSupportDir, 'snapshot.json'), + claudeSnapshotPath: join(appSupportDir, 'claude-rate-limits.json'), + cliWrapperPath: join(appSupportDir, 'tokenleak-menubar-cli'), + dashboardWrapperPath: join(appSupportDir, 'tokenleak-menubar-dashboard'), + claudeStatuslineWrapperPath: join(appSupportDir, 'tokenleak-menubar-claude-statusline'), + previousClaudeStatuslineCommandPath: join(appSupportDir, 'claude-statusline-original'), + installedAppPath: join(homeDir, 'Applications', 'Tokenleak Usage.app'), + appPlistPath: join(launchAgentsDir, `${MENUBAR_APP_LABEL}.plist`), + appLogPath: join(logsDir, 'app.log'), + daemonLogPath: join(logsDir, 'daemon.log'), + claudeSettingsPath: join(homeDir, '.claude', 'settings.json'), + }; +} diff --git a/packages/cli/src/menubar/state.test.ts b/packages/cli/src/menubar/state.test.ts new file mode 100644 index 0000000..b6f09a1 --- /dev/null +++ b/packages/cli/src/menubar/state.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveMenubarPaths } from './paths'; +import { + createDefaultMenubarConfig, + ensureClaudeStatusLineConfig, + refreshMenubarSnapshot, + writeClaudeBridgeSnapshot, + writeMenubarConfig, +} from './state'; +import { CURRENT_BRIDGE_VERSION } from './types'; + +function writeSession(root: string, relativePath: string, line: Record): void { + const fullPath = join(root, relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, `${JSON.stringify(line)}\n`); +} + +function writeClaudeSettings(paths: ReturnType, settings: Record): void { + mkdirSync(dirname(paths.claudeSettingsPath), { recursive: true }); + writeFileSync(paths.claudeSettingsPath, `${JSON.stringify(settings, null, 2)}\n`); +} + +function readClaudeSettings(paths: ReturnType): Record { + return JSON.parse(readFileSync(paths.claudeSettingsPath, 'utf8')) as Record; +} + +describe('refreshMenubarSnapshot', () => { + const tempDirs: string[] = []; + const originalCodexHome = process.env['CODEX_HOME']; + + afterEach(() => { + if (originalCodexHome === undefined) { + delete process.env['CODEX_HOME']; + } else { + process.env['CODEX_HOME'] = originalCodexHome; + } + + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('builds a compact dual-provider title from Codex and Claude snapshots', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-home-')); + const codexHome = mkdtempSync(join(tmpdir(), 'tokenleak-codex-home-')); + tempDirs.push(homeDir, codexHome); + process.env['CODEX_HOME'] = codexHome; + + writeSession(codexHome, 'sessions/2026/03/28/session.jsonl', { + timestamp: '2026-03-28T09:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: { + primary: { used_percent: 17, window_minutes: 300, resets_at: 4102444800 }, + secondary: { used_percent: 43, window_minutes: 10080, resets_at: 4103049600 }, + plan_type: 'plus', + }, + }, + }); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = true; + writeMenubarConfig(paths, config); + writeClaudeBridgeSnapshot(paths, { + schemaVersion: 1, + source: 'claude-statusline', + capturedAt: '2026-03-28T09:02:00.000Z', + planType: 'max', + fiveHour: { usedPercent: 62, windowMinutes: 300, resetAt: '2099-12-31T12:00:00.000Z' }, + sevenDay: { usedPercent: 54, windowMinutes: 10080, resetAt: '2099-12-31T12:00:00.000Z' }, + }); + + const snapshot = await refreshMenubarSnapshot(paths); + + expect(snapshot.title).toBe('Cdx 83% | Cld 38%'); + expect(snapshot.providers.codex.state).toBe('ready'); + expect(snapshot.providers.codex.planType).toBe('plus'); + expect(snapshot.providers.claudeCode.state).toBe('ready'); + expect(snapshot.providers.claudeCode.planType).toBe('max'); + }); + + it('marks Claude as waiting when the statusline bridge is configured but has no snapshot yet', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-home-')); + tempDirs.push(homeDir); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = true; + writeMenubarConfig(paths, config); + + const snapshot = await refreshMenubarSnapshot(paths); + + expect(snapshot.providers.claudeCode.state).toBe('waiting_for_first_snapshot'); + expect(snapshot.providers.claudeCode.message).toContain('trusted interactive workspace'); + expect(snapshot.title).toContain('Cld --'); + }); +}); + +describe('ensureClaudeStatusLineConfig', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('repairs settings.json when statusLine was overwritten', () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-heal-')); + tempDirs.push(homeDir); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = true; + config.claudeBridgeVersion = CURRENT_BRIDGE_VERSION; + writeMenubarConfig(paths, config); + + // Simulate user/Claude overwriting the statusLine + writeClaudeSettings(paths, { + statusLine: { type: 'command', command: 'sh ~/.claude/my-custom-statusline.sh' }, + }); + + const updated = ensureClaudeStatusLineConfig(paths, config); + + // Settings should be repaired + const settings = readClaudeSettings(paths); + const statusLine = settings['statusLine'] as Record; + expect(statusLine['command']).toBe(paths.claudeStatuslineWrapperPath); + + // Backup should capture the overwritten command + const backup = updated.claudeStatusLineBackup as Record; + expect(backup['command']).toBe('sh ~/.claude/my-custom-statusline.sh'); + + // Original command script should exist + expect(existsSync(paths.previousClaudeStatuslineCommandPath)).toBe(true); + }); + + it('does nothing when settings.json already points to our bridge', () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-heal-')); + tempDirs.push(homeDir); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = true; + config.claudeBridgeVersion = CURRENT_BRIDGE_VERSION; + writeMenubarConfig(paths, config); + + writeClaudeSettings(paths, { + statusLine: { type: 'command', command: paths.claudeStatuslineWrapperPath }, + }); + + const updated = ensureClaudeStatusLineConfig(paths, config); + + // No change — backup should remain null + expect(updated.claudeStatusLineBackup).toBeNull(); + }); + + it('skips repair when claudeStatusLineManaged is false', () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-heal-')); + tempDirs.push(homeDir); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = false; + writeMenubarConfig(paths, config); + + writeClaudeSettings(paths, { + statusLine: { type: 'command', command: 'something-else' }, + }); + + const updated = ensureClaudeStatusLineConfig(paths, config); + + // Settings should NOT be modified + const settings = readClaudeSettings(paths); + const statusLine = settings['statusLine'] as Record; + expect(statusLine['command']).toBe('something-else'); + expect(updated.claudeStatusLineManaged).toBe(false); + }); + + it('upgrades bridge script when claudeBridgeVersion is outdated', () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-heal-')); + tempDirs.push(homeDir); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = true; + config.claudeBridgeVersion = 0; // Old version + writeMenubarConfig(paths, config); + + // Settings already point to our wrapper — but bridge version is old + writeClaudeSettings(paths, { + statusLine: { type: 'command', command: paths.claudeStatuslineWrapperPath }, + }); + + const updated = ensureClaudeStatusLineConfig(paths, config); + + expect(updated.claudeBridgeVersion).toBe(CURRENT_BRIDGE_VERSION); + // Bridge script should have been regenerated + expect(existsSync(paths.claudeStatuslineWrapperPath)).toBe(true); + const bridgeContent = readFileSync(paths.claudeStatuslineWrapperPath, 'utf8'); + expect(bridgeContent).toContain('/usr/bin/python3'); + }); +}); diff --git a/packages/cli/src/menubar/state.ts b/packages/cli/src/menubar/state.ts new file mode 100644 index 0000000..f6f77df --- /dev/null +++ b/packages/cli/src/menubar/state.ts @@ -0,0 +1,414 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname } from 'node:path'; +import { + extractClaudeQuotaSnapshot, + extractCodexQuotaSnapshot, + type ClaudeQuotaSnapshot, + type CodexQuotaSnapshot, + type QuotaWindowSnapshot, +} from '@tokenleak/registry'; +import type { + ClaudeBridgeSnapshot, + MenubarConfig, + MenubarPaths, + MenubarProviderSnapshot, + MenubarSnapshot, + MenubarWindowSnapshot, +} from './types.js'; +import { + CLAUDE_STATUSLINE_SETUP_MESSAGE, + CURRENT_BRIDGE_VERSION, + DEFAULT_MENUBAR_POLL_INTERVAL_SECONDS, + MENUBAR_SCHEMA_VERSION, +} from './types.js'; +import { toRemainingPercent } from './format.js'; +import { buildClaudeStatuslineBridge, buildOriginalClaudeStatuslineCommandScript } from './launchd.js'; + +const WINDOW_STALE_GRACE_MS = 5 * 60 * 1000; + +function createEmptyWindow(label: string, windowMinutes: number): MenubarWindowSnapshot { + return { + label, + usedPercent: null, + resetAt: null, + windowMinutes, + isStale: false, + }; +} + +function toMenubarWindow( + label: string, + value: QuotaWindowSnapshot | null, + nowMs: number, + fallbackMinutes: number, +): MenubarWindowSnapshot { + if (!value) { + return createEmptyWindow(label, fallbackMinutes); + } + + const resetMs = value.resetAt ? Date.parse(value.resetAt) : Number.NaN; + const isStale = Number.isFinite(resetMs) ? nowMs > resetMs + WINDOW_STALE_GRACE_MS : false; + + return { + label, + usedPercent: value.usedPercent, + resetAt: value.resetAt, + windowMinutes: value.windowMinutes, + isStale, + }; +} + +function createProviderSnapshot( + base: Omit, + fiveHour: MenubarWindowSnapshot, + sevenDay: MenubarWindowSnapshot, +): MenubarProviderSnapshot { + return { + ...base, + windows: { + fiveHour, + sevenDay, + }, + }; +} + +function buildCodexSnapshot( + snapshot: CodexQuotaSnapshot | null, + error: string | null, + nowMs: number, +): MenubarProviderSnapshot { + const fiveHour = toMenubarWindow('5h', snapshot?.fiveHour ?? null, nowMs, 300); + const sevenDay = toMenubarWindow('7d', snapshot?.sevenDay ?? null, nowMs, 10080); + + if (error) { + return createProviderSnapshot( + { + label: 'Codex', + shortLabel: 'Cdx', + source: 'codex-log', + state: 'error', + planType: null, + lastUpdatedAt: null, + message: error, + }, + fiveHour, + sevenDay, + ); + } + + if (!snapshot) { + return createProviderSnapshot( + { + label: 'Codex', + shortLabel: 'Cdx', + source: 'codex-log', + state: 'setup_required', + planType: null, + lastUpdatedAt: null, + message: 'Use Codex once to generate a local quota snapshot.', + }, + fiveHour, + sevenDay, + ); + } + + const state = fiveHour.isStale && sevenDay.isStale ? 'stale' : 'ready'; + return createProviderSnapshot( + { + label: 'Codex', + shortLabel: 'Cdx', + source: 'codex-log', + state, + planType: snapshot.planType, + lastUpdatedAt: snapshot.capturedAt, + message: state === 'stale' ? 'Codex quota data is older than the last reset.' : null, + }, + fiveHour, + sevenDay, + ); +} + +function buildClaudeSnapshot( + snapshot: ClaudeQuotaSnapshot | null, + error: string | null, + config: MenubarConfig, + nowMs: number, +): MenubarProviderSnapshot { + const fiveHour = toMenubarWindow('5h', snapshot?.fiveHour ?? null, nowMs, 300); + const sevenDay = toMenubarWindow('7d', snapshot?.sevenDay ?? null, nowMs, 10080); + + if (error) { + return createProviderSnapshot( + { + label: 'Claude Code', + shortLabel: 'Cld', + source: 'claude-statusline', + state: 'error', + planType: null, + lastUpdatedAt: null, + message: error, + }, + fiveHour, + sevenDay, + ); + } + + if (!snapshot) { + return createProviderSnapshot( + { + label: 'Claude Code', + shortLabel: 'Cld', + source: 'claude-statusline', + state: config.claudeStatusLineManaged ? 'waiting_for_first_snapshot' : 'setup_required', + planType: null, + lastUpdatedAt: null, + message: config.claudeStatusLineManaged + ? CLAUDE_STATUSLINE_SETUP_MESSAGE + : 'Claude Code statusline bridge is not configured.', + }, + fiveHour, + sevenDay, + ); + } + + const state = fiveHour.isStale && sevenDay.isStale ? 'stale' : 'ready'; + return createProviderSnapshot( + { + label: 'Claude Code', + shortLabel: 'Cld', + source: 'claude-statusline', + state, + planType: snapshot.planType, + lastUpdatedAt: snapshot.capturedAt, + message: state === 'stale' ? 'Claude quota data is older than the last reset.' : null, + }, + fiveHour, + sevenDay, + ); +} + +function titlePercent(provider: MenubarProviderSnapshot): string { + const value = provider.windows.fiveHour; + if (provider.state !== 'ready' || value.isStale || typeof value.usedPercent !== 'number') { + return '--'; + } + + const remaining = toRemainingPercent(value.usedPercent); + return typeof remaining === 'number' ? `${Math.round(remaining)}%` : '--'; +} + +export function ensureMenubarDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +export function writeJsonAtomic(path: string, value: unknown): void { + ensureMenubarDir(dirname(path)); + const tmpPath = `${path}.tmp`; + writeFileSync(tmpPath, `${JSON.stringify(value, null, 2)}\n`); + renameSync(tmpPath, path); +} + +export function writeExecutableScript(path: string, content: string): void { + ensureMenubarDir(dirname(path)); + writeFileSync(path, content); + chmodSync(path, 0o755); +} + +export function readMenubarConfig(paths: MenubarPaths): MenubarConfig { + if (!existsSync(paths.configPath)) { + return createDefaultMenubarConfig(); + } + + const raw = JSON.parse(readFileSync(paths.configPath, 'utf8')) as Partial; + return { + schemaVersion: MENUBAR_SCHEMA_VERSION, + pollIntervalSeconds: + typeof raw.pollIntervalSeconds === 'number' && raw.pollIntervalSeconds >= 10 + ? Math.round(raw.pollIntervalSeconds) + : DEFAULT_MENUBAR_POLL_INTERVAL_SECONDS, + claudeStatusLineManaged: raw.claudeStatusLineManaged === true, + claudeStatusLineBackup: raw.claudeStatusLineBackup ?? null, + claudeBridgeVersion: typeof raw.claudeBridgeVersion === 'number' ? raw.claudeBridgeVersion : 0, + }; +} + +export function createDefaultMenubarConfig(): MenubarConfig { + return { + schemaVersion: MENUBAR_SCHEMA_VERSION, + pollIntervalSeconds: DEFAULT_MENUBAR_POLL_INTERVAL_SECONDS, + claudeStatusLineManaged: false, + claudeStatusLineBackup: null, + claudeBridgeVersion: 0, + }; +} + +export function writeMenubarConfig(paths: MenubarPaths, config: MenubarConfig): void { + writeJsonAtomic(paths.configPath, config); +} + +export function readSnapshot(paths: MenubarPaths): MenubarSnapshot | null { + if (!existsSync(paths.snapshotPath)) { + return null; + } + + try { + return JSON.parse(readFileSync(paths.snapshotPath, 'utf8')) as MenubarSnapshot; + } catch { + return null; + } +} + +// Keep for backward compatibility with tests that write bridge snapshots directly +export function writeClaudeBridgeSnapshot( + paths: MenubarPaths, + snapshot: ClaudeBridgeSnapshot, +): void { + writeJsonAtomic(paths.claudeSnapshotPath, snapshot); +} + +export function writeSnapshot(paths: MenubarPaths, snapshot: MenubarSnapshot): void { + writeJsonAtomic(paths.snapshotPath, snapshot); +} + +// --------------------------------------------------------------------------- +// Self-healing: detect when ~/.claude/settings.json statusLine was overwritten +// and auto-repair it to point back to the tokenleak bridge script. +// --------------------------------------------------------------------------- + +interface CommandStatusLine { + type: 'command'; + command: string; +} + +function readClaudeSettings(paths: MenubarPaths): Record { + if (!existsSync(paths.claudeSettingsPath)) { + return {}; + } + + try { + return JSON.parse(readFileSync(paths.claudeSettingsPath, 'utf8')) as Record; + } catch { + return {}; + } +} + +function writeClaudeSettings(paths: MenubarPaths, settings: Record): void { + ensureMenubarDir(dirname(paths.claudeSettingsPath)); + writeFileSync(paths.claudeSettingsPath, `${JSON.stringify(settings, null, 2)}\n`); +} + +function parseCommandStatusLine(setting: unknown): CommandStatusLine | null { + if (typeof setting !== 'object' || setting === null) { + return null; + } + + const record = setting as Record; + if (record['type'] !== 'command' || typeof record['command'] !== 'string') { + return null; + } + + return { type: 'command', command: record['command'] }; +} + +function isManagedStatusLine(paths: MenubarPaths, value: unknown): boolean { + const parsed = parseCommandStatusLine(value); + return parsed?.command === paths.claudeStatuslineWrapperPath; +} + +function managedStatusLineSetting(paths: MenubarPaths): CommandStatusLine { + return { type: 'command', command: paths.claudeStatuslineWrapperPath }; +} + +export function ensureClaudeStatusLineConfig( + paths: MenubarPaths, + config: MenubarConfig, +): MenubarConfig { + if (!config.claudeStatusLineManaged) { + return config; + } + + const settings = readClaudeSettings(paths); + const needsSettingsRepair = !isManagedStatusLine(paths, settings['statusLine']); + const needsBridgeUpgrade = config.claudeBridgeVersion < CURRENT_BRIDGE_VERSION; + + if (!needsSettingsRepair && !needsBridgeUpgrade) { + return config; + } + + // If settings were overwritten, capture the new command as the "original" + if (needsSettingsRepair) { + const current = parseCommandStatusLine(settings['statusLine']); + if (current) { + config.claudeStatusLineBackup = settings['statusLine']; + writeExecutableScript( + paths.previousClaudeStatuslineCommandPath, + buildOriginalClaudeStatuslineCommandScript(current.command), + ); + } + + settings['statusLine'] = managedStatusLineSetting(paths); + writeClaudeSettings(paths, settings); + } + + // Regenerate the bridge script (handles both repair and upgrade) + writeExecutableScript(paths.claudeStatuslineWrapperPath, buildClaudeStatuslineBridge(paths)); + config.claudeBridgeVersion = CURRENT_BRIDGE_VERSION; + writeMenubarConfig(paths, config); + + return config; +} + +// Re-export for install.ts +export { isManagedStatusLine as isManagedClaudeStatusLineSetting }; + +export async function refreshMenubarSnapshot(paths: MenubarPaths): Promise { + const config = readMenubarConfig(paths); + const now = new Date(); + const nowMs = now.getTime(); + + let codexSnapshot: CodexQuotaSnapshot | null = null; + let codexError: string | null = null; + try { + codexSnapshot = await extractCodexQuotaSnapshot(); + } catch (error: unknown) { + codexError = error instanceof Error ? error.message : String(error); + } + + let claudeSnapshot: ClaudeQuotaSnapshot | null = null; + let claudeError: string | null = null; + try { + claudeSnapshot = extractClaudeQuotaSnapshot(paths.claudeSnapshotPath); + } catch (error: unknown) { + claudeError = error instanceof Error ? error.message : String(error); + } + + const codex = buildCodexSnapshot(codexSnapshot, codexError, nowMs); + const claudeCode = buildClaudeSnapshot(claudeSnapshot, claudeError, config, nowMs); + const snapshot: MenubarSnapshot = { + schemaVersion: MENUBAR_SCHEMA_VERSION, + generatedAt: now.toISOString(), + title: `${codex.shortLabel} ${titlePercent(codex)} | ${claudeCode.shortLabel} ${titlePercent( + claudeCode, + )}`, + providers: { + codex, + claudeCode, + }, + }; + + writeSnapshot(paths, snapshot); + return snapshot; +} + +export function clearMenubarState(paths: MenubarPaths): void { + rmSync(paths.snapshotPath, { force: true }); + rmSync(paths.claudeSnapshotPath, { force: true }); +} diff --git a/packages/cli/src/menubar/types.ts b/packages/cli/src/menubar/types.ts new file mode 100644 index 0000000..cb9291f --- /dev/null +++ b/packages/cli/src/menubar/types.ts @@ -0,0 +1,87 @@ +export const MENUBAR_SCHEMA_VERSION = 1; +export const DEFAULT_MENUBAR_POLL_INTERVAL_SECONDS = 30; +export const CLAUDE_STATUSLINE_SETUP_MESSAGE = + 'Claude live quota data has not arrived yet. Use Claude Code in a trusted interactive workspace and get one response.'; + +export type MenubarProviderState = + | 'ready' + | 'setup_required' + | 'waiting_for_first_snapshot' + | 'stale' + | 'error'; + +export interface StoredQuotaWindow { + usedPercent: number; + windowMinutes: number; + resetAt: string | null; +} + +export interface ClaudeBridgeSnapshot { + schemaVersion: number; + source: 'claude-statusline'; + capturedAt: string; + planType: string | null; + fiveHour: StoredQuotaWindow | null; + sevenDay: StoredQuotaWindow | null; +} + +export interface MenubarWindowSnapshot { + label: string; + usedPercent: number | null; + resetAt: string | null; + windowMinutes: number; + isStale: boolean; +} + +export interface MenubarProviderSnapshot { + label: string; + shortLabel: string; + source: string; + state: MenubarProviderState; + planType: string | null; + lastUpdatedAt: string | null; + message: string | null; + windows: { + fiveHour: MenubarWindowSnapshot; + sevenDay: MenubarWindowSnapshot; + }; +} + +export interface MenubarSnapshot { + schemaVersion: number; + generatedAt: string; + title: string; + providers: { + codex: MenubarProviderSnapshot; + claudeCode: MenubarProviderSnapshot; + }; +} + +export const CURRENT_BRIDGE_VERSION = 2; + +export interface MenubarConfig { + schemaVersion: number; + pollIntervalSeconds: number; + claudeStatusLineManaged: boolean; + claudeStatusLineBackup: unknown | null; + claudeBridgeVersion: number; +} + +export interface MenubarPaths { + homeDir: string; + appSupportDir: string; + logsDir: string; + launchAgentsDir: string; + configPath: string; + snapshotPath: string; + claudeSnapshotPath: string; + cliWrapperPath: string; + dashboardWrapperPath: string; + claudeStatuslineWrapperPath: string; + previousClaudeStatuslineCommandPath: string; + installedAppPath: string; + appPlistPath: string; + appLogPath: string; + daemonLogPath: string; + claudeSettingsPath: string; +} diff --git a/packages/menubar/App/AppDelegate.swift b/packages/menubar/App/AppDelegate.swift new file mode 100644 index 0000000..47e9d86 --- /dev/null +++ b/packages/menubar/App/AppDelegate.swift @@ -0,0 +1,193 @@ +import AppKit +import Combine +import SwiftUI + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + private let popover = NSPopover() + private let refreshTimer = TimerManager() + private var settingsWindowController: SettingsWindowController? + private var statusHostingView: NSHostingView? + private var subscriptions = Set() + private var daemonProcess: Process? + + private lazy var viewModel: UsageViewModel = { + UsageViewModel( + homeDirectory: homeDirectory, + supportDirectory: supportDirectory, + snapshotPath: snapshotPath, + cliWrapperPath: cliWrapperPath, + dashboardWrapperPath: dashboardWrapperPath + ) + }() + + private let homeDirectory: String + private let supportDirectory: String + private let snapshotPath: String + private let cliWrapperPath: String + private let dashboardWrapperPath: String + private let daemonLogPath: String + + override init() { + let homeDirectory = ProcessInfo.processInfo.environment["TOKENLEAK_MENUBAR_HOME"] ?? NSHomeDirectory() + self.homeDirectory = homeDirectory + self.supportDirectory = "\(homeDirectory)/Library/Application Support/tokenleak/menubar" + self.snapshotPath = "\(supportDirectory)/snapshot.json" + self.cliWrapperPath = "\(supportDirectory)/tokenleak-menubar-cli" + self.dashboardWrapperPath = "\(supportDirectory)/tokenleak-menubar-dashboard" + self.daemonLogPath = "\(supportDirectory)/logs/daemon.log" + super.init() + } + + private var daemonCheckCounter = 0 + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + configureStatusItem() + configurePopover() + bindViewModel() + + startDaemonIfNeeded() + viewModel.reloadSnapshot(animated: false) + + refreshTimer.start(every: 5, fireImmediately: false) { [weak self] in + guard let self else { return } + // Only check daemon every 6th tick (30s) instead of every 5s + self.daemonCheckCounter += 1 + if self.daemonCheckCounter % 6 == 0 { + self.startDaemonIfNeeded() + } + self.viewModel.reloadSnapshot(animated: false) + } + } + + func applicationWillTerminate(_ notification: Notification) { + refreshTimer.stop() + daemonProcess?.terminate() + } + + @objc func togglePopover(_ sender: Any?) { + guard let button = statusItem.button else { + return + } + + if popover.isShown { + popover.performClose(sender) + } else { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + } + + @objc func openSettingsWindow(_ sender: Any?) { + popover.performClose(sender) + + if settingsWindowController == nil { + settingsWindowController = SettingsWindowController(viewModel: viewModel) + } + settingsWindowController?.present() + } + + private func configureStatusItem() { + guard let button = statusItem.button else { + return + } + + button.target = self + button.action = #selector(togglePopover(_:)) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + button.image = nil + button.title = "" + + let hostingView = NSHostingView(rootView: MenuBarStatusItemView(label: viewModel.statusLabel, tintColor: Color(nsColor: viewModel.statusTint))) + hostingView.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 4), + hostingView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -4), + hostingView.topAnchor.constraint(equalTo: button.topAnchor, constant: 1), + hostingView.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -1), + ]) + + self.statusHostingView = hostingView + } + + private func configurePopover() { + popover.behavior = .transient + popover.animates = false + popover.contentSize = NSSize(width: 360, height: 400) + popover.contentViewController = NSHostingController( + rootView: PopoverContentView(viewModel: viewModel, onOpenSettings: { [weak self] in + self?.openSettingsWindow(nil) + }) + ) + } + + private func bindViewModel() { + Publishers.CombineLatest3(viewModel.$claudeUsage, viewModel.$codexUsage, viewModel.$lastUpdatedText) + .receive(on: RunLoop.main) + .sink { [weak self] _, _, _ in + self?.updateStatusItem() + } + .store(in: &subscriptions) + } + + private func updateStatusItem() { + statusHostingView?.rootView = MenuBarStatusItemView( + label: viewModel.statusLabel, + tintColor: Color(nsColor: viewModel.statusTint) + ) + } + + private func startDaemonIfNeeded() { + guard daemonProcess?.isRunning != true else { + return + } + + guard FileManager.default.isExecutableFile(atPath: cliWrapperPath) else { + return + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: cliWrapperPath) + process.arguments = ["menubar", "daemon", "--home", homeDirectory] + + FileManager.default.createFile(atPath: daemonLogPath, contents: nil) + if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: daemonLogPath)) { + _ = try? handle.seekToEnd() + process.standardOutput = handle + process.standardError = handle + } + + do { + try process.run() + daemonProcess = process + } catch { + NSSound.beep() + } + } +} + +private struct MenuBarStatusItemView: View { + let label: String? + let tintColor: Color + + var body: some View { + HStack(spacing: 5) { + MenuBarGlyph(color: tintColor) + .frame(width: 14, height: 14) + + if let label { + Text(label) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(tintColor) + } + } + .padding(.horizontal, 4) + .frame(height: 20) + .allowsHitTesting(false) + } +} diff --git a/packages/menubar/App/LLMUsageApp.swift b/packages/menubar/App/LLMUsageApp.swift new file mode 100644 index 0000000..adf2041 --- /dev/null +++ b/packages/menubar/App/LLMUsageApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct LLMUsageApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + Settings { + EmptyView() + } + } +} diff --git a/packages/menubar/App/Services/ClaudeUsageService.swift b/packages/menubar/App/Services/ClaudeUsageService.swift new file mode 100644 index 0000000..335059d --- /dev/null +++ b/packages/menubar/App/Services/ClaudeUsageService.swift @@ -0,0 +1,21 @@ +import Foundation + +final class ClaudeUsageService: UsageService { + let kind: UsageProviderKind = .claude + + func resolveUsage(from snapshot: MenuBarSnapshot?) -> UsageCardState { + let provider = snapshot?.providers.claudeCode + let windows = makeUsageWindows(from: provider) + + return UsageCardState( + kind: kind, + serviceName: kind.title, + modelLabel: descriptorText(planType: provider?.planType, fallback: kind.fallbackDescriptor), + state: provider?.state ?? .setupRequired, + message: provider?.message, + windows: windows.isEmpty ? [placeholderWindow(label: "5H"), placeholderWindow(label: "7D")] : windows, + primaryWindow: pickPrimaryWindow(from: windows), + lastUpdatedAt: parseSnapshotDate(provider?.lastUpdatedAt) + ) + } +} diff --git a/packages/menubar/App/Services/CodexUsageService.swift b/packages/menubar/App/Services/CodexUsageService.swift new file mode 100644 index 0000000..509891f --- /dev/null +++ b/packages/menubar/App/Services/CodexUsageService.swift @@ -0,0 +1,21 @@ +import Foundation + +final class CodexUsageService: UsageService { + let kind: UsageProviderKind = .codex + + func resolveUsage(from snapshot: MenuBarSnapshot?) -> UsageCardState { + let provider = snapshot?.providers.codex + let windows = makeUsageWindows(from: provider) + + return UsageCardState( + kind: kind, + serviceName: kind.title, + modelLabel: descriptorText(planType: provider?.planType, fallback: kind.fallbackDescriptor), + state: provider?.state ?? .setupRequired, + message: provider?.message, + windows: windows.isEmpty ? [placeholderWindow(label: "5H"), placeholderWindow(label: "7D")] : windows, + primaryWindow: pickPrimaryWindow(from: windows), + lastUpdatedAt: parseSnapshotDate(provider?.lastUpdatedAt) + ) + } +} diff --git a/packages/menubar/App/Services/UsageService.swift b/packages/menubar/App/Services/UsageService.swift new file mode 100644 index 0000000..d6c1392 --- /dev/null +++ b/packages/menubar/App/Services/UsageService.swift @@ -0,0 +1,250 @@ +import AppKit +import Foundation +import SwiftUI + +enum UsageProviderKind: String { + case claude + case codex + + var title: String { + switch self { + case .claude: + return "Claude Code" + case .codex: + return "Codex" + } + } + + var fallbackDescriptor: String { + "Active quota" + } +} + +enum UsageProviderState: String, Decodable { + case ready + case setupRequired = "setup_required" + case waitingForFirstSnapshot = "waiting_for_first_snapshot" + case stale + case error + + var displayLabel: String { + switch self { + case .ready: + return "Ready" + case .setupRequired: + return "Setup required" + case .waitingForFirstSnapshot: + return "Waiting for first snapshot" + case .stale: + return "Snapshot stale" + case .error: + return "Unavailable" + } + } +} + +struct SnapshotWindow: Decodable { + let label: String + let usedPercent: Double? + let resetAt: String? + let windowMinutes: Int + let isStale: Bool +} + +struct SnapshotWindowGroup: Decodable { + let fiveHour: SnapshotWindow + let sevenDay: SnapshotWindow +} + +struct SnapshotProvider: Decodable { + let label: String + let shortLabel: String + let source: String + let state: UsageProviderState + let planType: String? + let lastUpdatedAt: String? + let message: String? + let windows: SnapshotWindowGroup +} + +struct SnapshotProviders: Decodable { + let codex: SnapshotProvider + let claudeCode: SnapshotProvider +} + +struct MenuBarSnapshot: Decodable { + let schemaVersion: Int + let generatedAt: String + let title: String + let providers: SnapshotProviders +} + +struct UsageWindowState: Identifiable { + let id: String + let label: String + let usedPercent: Double? + let remainingPercent: Double? + let resetAt: Date? + let isStale: Bool + let windowMinutes: Int + + var progress: Double { + guard let remainingPercent else { + return 0 + } + return max(0, min(1, remainingPercent / 100)) + } + + var compactLabel: String { + guard let remainingPercent else { + return "\(label) --" + } + return "\(label) \(Int(remainingPercent.rounded()))%" + } + + var remainingText: String { + guard let remainingPercent else { + return "--%" + } + return "\(Int(remainingPercent.rounded()))%" + } + + var resetText: String { + guard let resetAt else { + return "Reset time unavailable" + } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + let relative = formatter.localizedString(for: resetAt, relativeTo: Date()) + if resetAt < Date() { + return "Reset imminent" + } + return "Resets \(relative)" + } +} + +struct UsageCardState { + let kind: UsageProviderKind + let serviceName: String + let modelLabel: String + let state: UsageProviderState + let message: String? + let windows: [UsageWindowState] + let primaryWindow: UsageWindowState + let lastUpdatedAt: Date? + + var displayPercent: Double? { + guard state == .ready else { + return nil + } + return primaryWindow.remainingPercent + } + + var headerDetail: String { + if state == .ready { + return modelLabel + } + return state.displayLabel + } + + var footerText: String { + if state == .ready { + return primaryWindow.resetText + } + return message ?? state.displayLabel + } + + var primaryProgress: Double { + primaryWindow.progress + } + + var usedPercent: Double { + guard let usedPercent = primaryWindow.usedPercent else { + return 0 + } + return max(0, min(100, usedPercent)) + } +} + +protocol UsageService { + var kind: UsageProviderKind { get } + func resolveUsage(from snapshot: MenuBarSnapshot?) -> UsageCardState +} + +func parseSnapshotDate(_ value: String?) -> Date? { + guard let value else { + return nil + } + + return UsageFormatters.iso8601.date(from: value) +} + +func remainingPercent(from usedPercent: Double?) -> Double? { + guard let usedPercent else { + return nil + } + return max(0, min(100, 100 - usedPercent)) +} + +func makeUsageWindows(from provider: SnapshotProvider?) -> [UsageWindowState] { + let windows = [ + provider?.windows.fiveHour, + provider?.windows.sevenDay, + ] + + return windows.compactMap { snapshotWindow in + guard let snapshotWindow else { + return nil + } + + return UsageWindowState( + id: snapshotWindow.label, + label: snapshotWindow.label.uppercased(), + usedPercent: snapshotWindow.usedPercent, + remainingPercent: snapshotWindow.isStale ? nil : remainingPercent(from: snapshotWindow.usedPercent), + resetAt: parseSnapshotDate(snapshotWindow.resetAt), + isStale: snapshotWindow.isStale, + windowMinutes: snapshotWindow.windowMinutes + ) + } +} + +func placeholderWindow(label: String) -> UsageWindowState { + UsageWindowState( + id: label, + label: label, + usedPercent: nil, + remainingPercent: nil, + resetAt: nil, + isStale: false, + windowMinutes: 0 + ) +} + +func pickPrimaryWindow(from windows: [UsageWindowState]) -> UsageWindowState { + let valid = windows.filter { !$0.isStale && $0.remainingPercent != nil } + if let mostConstrained = valid.min(by: { ($0.remainingPercent ?? 101) < ($1.remainingPercent ?? 101) }) { + return mostConstrained + } + return windows.first ?? placeholderWindow(label: "5H") +} + +func descriptorText(planType: String?, fallback: String) -> String { + guard let planType, !planType.isEmpty else { + return fallback + } + + let pieces = planType + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { $0.capitalized } + return pieces.joined(separator: " ") +} + +enum UsageFormatters { + static let iso8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() +} diff --git a/packages/menubar/App/Settings/SettingsView.swift b/packages/menubar/App/Settings/SettingsView.swift new file mode 100644 index 0000000..1aa37c9 --- /dev/null +++ b/packages/menubar/App/Settings/SettingsView.swift @@ -0,0 +1,117 @@ +import AppKit +import SwiftUI + +struct SettingsView: View { + @ObservedObject var viewModel: UsageViewModel + let onOpenSupportFolder: () -> Void + let onOpenDashboard: () -> Void + + var body: some View { + ZStack { + VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow) + .overlay(AppTheme.backgroundTint) + .overlay(AppTheme.backgroundOverlay) + + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text("Preferences") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(AppTheme.textPrimary) + Text("Tokenleak Usage reads local menubar snapshots and opens the dashboard on demand.") + .font(.system(size: 12)) + .foregroundStyle(AppTheme.textSecondary) + .lineLimit(2) + } + + SettingsRow(title: "Home", value: viewModel.homeDirectory) + SettingsRow(title: "Support", value: viewModel.supportDirectory) + SettingsRow(title: "Snapshot", value: viewModel.snapshotPath) + SettingsRow(title: "Updated", value: viewModel.lastUpdatedText) + + HStack(spacing: 10) { + actionButton("Open Support Folder", action: onOpenSupportFolder) + actionButton("Open Dashboard", action: onOpenDashboard) + } + + Spacer(minLength: 0) + } + .padding(22) + } + .frame(width: 460, height: 280) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(AppTheme.panelEdge, lineWidth: 0.6) + ) + } + + private func actionButton(_ title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.textPrimary) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + Capsule() + .fill(Color.white.opacity(0.06)) + ) + .overlay( + Capsule() + .stroke(Color.white.opacity(0.10), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + } +} + +private struct SettingsRow: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title.uppercased()) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(AppTheme.textTertiary) + Text(value) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } +} + +final class SettingsWindowController: NSWindowController { + init(viewModel: UsageViewModel) { + let rootView = SettingsView( + viewModel: viewModel, + onOpenSupportFolder: { viewModel.openSupportFolder() }, + onOpenDashboard: { viewModel.openDashboard() } + ) + let hostingController = NSHostingController(rootView: rootView) + + let window = NSWindow(contentViewController: hostingController) + window.title = "Tokenleak Usage Preferences" + window.styleMask = [.titled, .closable, .miniaturizable] + window.isReleasedWhenClosed = false + window.isMovableByWindowBackground = true + window.center() + + super.init(window: window) + shouldCascadeWindows = false + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func present() { + NSApp.activate(ignoringOtherApps: true) + showWindow(nil) + window?.makeKeyAndOrderFront(nil) + } +} diff --git a/packages/menubar/App/Utilities/KeychainHelper.swift b/packages/menubar/App/Utilities/KeychainHelper.swift new file mode 100644 index 0000000..112ab2f --- /dev/null +++ b/packages/menubar/App/Utilities/KeychainHelper.swift @@ -0,0 +1,55 @@ +import Foundation +import Security + +enum KeychainHelper { + static func save(value: Data, service: String, account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + + SecItemDelete(query as CFDictionary) + + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: value, + ] + + let status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + } + + static func load(service: String, account: String) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + return item as? Data + } + + static func delete(service: String, account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/packages/menubar/App/Utilities/Theme.swift b/packages/menubar/App/Utilities/Theme.swift new file mode 100644 index 0000000..8ba3250 --- /dev/null +++ b/packages/menubar/App/Utilities/Theme.swift @@ -0,0 +1,56 @@ +import AppKit +import SwiftUI + +enum AppTheme { + static let backgroundTint = Color(hex: 0x0A0A0F, opacity: 0.78) + static let backgroundOverlay = Color.black.opacity(0.18) + static let panelEdge = Color.white.opacity(0.10) + static let cardFill = Color.white.opacity(0.04) + static let cardFillHover = Color.white.opacity(0.07) + static let divider = Color.white.opacity(0.06) + static let separatorGradient = LinearGradient( + colors: [Color.white.opacity(0), Color.white.opacity(0.08), Color.white.opacity(0)], + startPoint: .leading, + endPoint: .trailing + ) + static let textPrimary = Color(hex: 0xF0EEFF) + static let textSecondary = Color(hex: 0xF0EEFF, opacity: 0.45) + static let textTertiary = Color(hex: 0xF0EEFF, opacity: 0.28) + static let statusHealthy = NSColor(hex: 0x5ED486) + static let statusWarning = NSColor(hex: 0xF4B657) + static let statusCritical = NSColor(hex: 0xFF5C74) + static let statusNeutral = NSColor(hex: 0xB6B1CC) +} + +extension UsageProviderKind { + var gradient: [Color] { + switch self { + case .claude: + return [Color(hex: 0xD97757), Color(hex: 0xE8996A)] + case .codex: + return [Color(hex: 0x10A37F), Color(hex: 0x6FCF97)] + } + } + + var markColor: Color { + gradient.first ?? .white + } +} + +extension Color { + init(hex: UInt64, opacity: Double = 1) { + let red = Double((hex >> 16) & 0xFF) / 255 + let green = Double((hex >> 8) & 0xFF) / 255 + let blue = Double(hex & 0xFF) / 255 + self.init(.sRGB, red: red, green: green, blue: blue, opacity: opacity) + } +} + +extension NSColor { + convenience init(hex: UInt64, alpha: CGFloat = 1) { + let red = CGFloat((hex >> 16) & 0xFF) / 255 + let green = CGFloat((hex >> 8) & 0xFF) / 255 + let blue = CGFloat(hex & 0xFF) / 255 + self.init(srgbRed: red, green: green, blue: blue, alpha: alpha) + } +} diff --git a/packages/menubar/App/Utilities/TimerManager.swift b/packages/menubar/App/Utilities/TimerManager.swift new file mode 100644 index 0000000..ef56294 --- /dev/null +++ b/packages/menubar/App/Utilities/TimerManager.swift @@ -0,0 +1,28 @@ +import Foundation + +final class TimerManager { + private var timer: Timer? + + func start(every interval: TimeInterval, fireImmediately: Bool = false, action: @escaping () -> Void) { + stop() + + if fireImmediately { + action() + } + + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + action() + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + func stop() { + timer?.invalidate() + timer = nil + } + + deinit { + stop() + } +} diff --git a/packages/menubar/App/ViewModels/UsageViewModel.swift b/packages/menubar/App/ViewModels/UsageViewModel.swift new file mode 100644 index 0000000..93f3198 --- /dev/null +++ b/packages/menubar/App/ViewModels/UsageViewModel.swift @@ -0,0 +1,155 @@ +import AppKit +import Foundation +import SwiftUI + +@MainActor +final class UsageViewModel: ObservableObject { + @Published private(set) var claudeUsage: UsageCardState + @Published private(set) var codexUsage: UsageCardState + @Published private(set) var lastUpdatedText: String + @Published private(set) var lastUpdatedAt: Date? + @Published private(set) var isRefreshing: Bool + + let homeDirectory: String + let supportDirectory: String + let snapshotPath: String + let cliWrapperPath: String + let dashboardWrapperPath: String + + private let services: [UsageService] + private let decoder = JSONDecoder() + + init( + homeDirectory: String, + supportDirectory: String, + snapshotPath: String, + cliWrapperPath: String, + dashboardWrapperPath: String, + services: [UsageService] = [ClaudeUsageService(), CodexUsageService()] + ) { + self.homeDirectory = homeDirectory + self.supportDirectory = supportDirectory + self.snapshotPath = snapshotPath + self.cliWrapperPath = cliWrapperPath + self.dashboardWrapperPath = dashboardWrapperPath + self.services = services + + self.claudeUsage = ClaudeUsageService().resolveUsage(from: nil) + self.codexUsage = CodexUsageService().resolveUsage(from: nil) + self.lastUpdatedText = "Waiting for first refresh" + self.isRefreshing = false + } + + var cards: [UsageCardState] { + [claudeUsage, codexUsage] + } + + var statusTint: NSColor { + guard let value = lowestRemainingPercent else { + return AppTheme.statusNeutral + } + if value < 20 { + return AppTheme.statusCritical + } + if value < 50 { + return AppTheme.statusWarning + } + return AppTheme.statusHealthy + } + + var statusLabel: String? { + guard let value = lowestRemainingPercent else { + return nil + } + return "\(Int(value.rounded()))%" + } + + private var lowestRemainingPercent: Double? { + cards.compactMap(\.displayPercent).min() + } + + func reloadSnapshot(animated: Bool = true) { + let snapshot = loadSnapshot() + let updatedClaude = services.first(where: { $0.kind == .claude })?.resolveUsage(from: snapshot) ?? ClaudeUsageService().resolveUsage(from: snapshot) + let updatedCodex = services.first(where: { $0.kind == .codex })?.resolveUsage(from: snapshot) ?? CodexUsageService().resolveUsage(from: snapshot) + let generatedAt = parseSnapshotDate(snapshot?.generatedAt) + + let updateState = { + self.claudeUsage = updatedClaude + self.codexUsage = updatedCodex + self.lastUpdatedAt = generatedAt + self.lastUpdatedText = self.makeLastUpdatedText(from: generatedAt) + } + + if animated { + withAnimation(.spring(response: 0.35, dampingFraction: 0.78)) { + updateState() + } + } else { + updateState() + } + } + + func refresh() { + guard !isRefreshing else { + return + } + + isRefreshing = true + let cliWrapperPath = self.cliWrapperPath + let homeDirectory = self.homeDirectory + + Task.detached(priority: .userInitiated) { + guard FileManager.default.isExecutableFile(atPath: cliWrapperPath) else { + return + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: cliWrapperPath) + process.arguments = ["menubar", "refresh", "--home", homeDirectory] + try? process.run() + process.waitUntilExit() + } + + Task { + try? await Task.sleep(for: .milliseconds(500)) + self.reloadSnapshot() + self.isRefreshing = false + } + } + + func openSupportFolder() { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: supportDirectory)]) + } + + func openDashboard() { + guard FileManager.default.isExecutableFile(atPath: dashboardWrapperPath) else { + NSSound.beep() + return + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-a", "Terminal", dashboardWrapperPath] + try? process.run() + } + + private func loadSnapshot() -> MenuBarSnapshot? { + let url = URL(fileURLWithPath: snapshotPath) + guard let data = try? Data(contentsOf: url) else { + return nil + } + + return try? decoder.decode(MenuBarSnapshot.self, from: data) + } + + private func makeLastUpdatedText(from date: Date?) -> String { + guard let date else { + return "Waiting for first refresh" + } + + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return "Last updated: \(formatter.localizedString(for: date, relativeTo: Date()))" + } +} diff --git a/packages/menubar/App/Views/ArcProgressRing.swift b/packages/menubar/App/Views/ArcProgressRing.swift new file mode 100644 index 0000000..e41b57f --- /dev/null +++ b/packages/menubar/App/Views/ArcProgressRing.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct ArcProgressRing: View { + let progress: Double + let gradient: [Color] + let critical: Bool + + @State private var animatedProgress: Double = 0 + @State private var glow = false + + var body: some View { + ZStack { + ArcTrackShape(progress: 1) + .stroke( + Color.white.opacity(0.08), + style: StrokeStyle(lineWidth: 7, lineCap: .round) + ) + + ArcTrackShape(progress: animatedProgress) + .stroke( + AngularGradient( + colors: gradient, + center: .center, + startAngle: .degrees(150), + endAngle: .degrees(390) + ), + style: StrokeStyle(lineWidth: 7, lineCap: .round) + ) + .shadow( + color: critical + ? Color.red.opacity(glow ? 0.35 : 0.14) + : (gradient.last ?? .white).opacity(0.34), + radius: critical ? (glow ? 12 : 6) : 8 + ) + } + .padding(6) + .onAppear { + withAnimation(.easeOut(duration: 0.7)) { + animatedProgress = progress + } + + guard critical else { + return + } + + withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) { + glow.toggle() + } + } + .onChange(of: progress) { _, newValue in + withAnimation(.easeOut(duration: 0.7)) { + animatedProgress = newValue + } + } + } +} + +private struct ArcTrackShape: Shape { + let progress: Double + + func path(in rect: CGRect) -> Path { + var path = Path() + let radius = min(rect.width, rect.height) / 2 + let center = CGPoint(x: rect.midX, y: rect.midY) + let startAngle = Angle.degrees(150) + let endAngle = Angle.degrees(150 + (240 * max(0, min(1, progress)))) + + path.addArc( + center: center, + radius: radius, + startAngle: startAngle, + endAngle: endAngle, + clockwise: false + ) + return path + } +} diff --git a/packages/menubar/App/Views/PopoverContentView.swift b/packages/menubar/App/Views/PopoverContentView.swift new file mode 100644 index 0000000..082d363 --- /dev/null +++ b/packages/menubar/App/Views/PopoverContentView.swift @@ -0,0 +1,114 @@ +import AppKit +import SwiftUI + +struct PopoverContentView: View { + @ObservedObject var viewModel: UsageViewModel + let onOpenSettings: () -> Void + + @State private var didAppear = false + + var body: some View { + ZStack { + VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow) + .overlay(AppTheme.backgroundTint) + .overlay(AppTheme.backgroundOverlay) + + VStack(alignment: .leading, spacing: 14) { + header + + Rectangle() + .fill(AppTheme.separatorGradient) + .frame(height: 0.5) + + VStack(spacing: 10) { + UsageCard(card: viewModel.claudeUsage) + UsageCard(card: viewModel.codexUsage) + } + + Text(viewModel.lastUpdatedText) + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundStyle(AppTheme.textSecondary) + .padding(.horizontal, 2) + } + .padding(16) + } + .frame(width: 360) + .opacity(didAppear ? 1 : 0) + .onAppear { + withAnimation(.easeOut(duration: 0.15)) { + didAppear = true + } + } + .onDisappear { + didAppear = false + } + } + + private var header: some View { + HStack(spacing: 12) { + MenuBarGlyph(color: Color(nsColor: viewModel.statusTint)) + .frame(width: 16, height: 16) + + VStack(alignment: .leading, spacing: 2) { + Text("LLM Usage") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppTheme.textPrimary) + Text("Codex and Claude Code quotas") + .font(.system(size: 11)) + .foregroundStyle(AppTheme.textSecondary) + } + + Spacer() + + Button { + viewModel.refresh() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AppTheme.textTertiary) + .frame(width: 26, height: 26) + .contentShape(Circle()) + } + .buttonStyle(.plain) + + Button { + onOpenSettings() + } label: { + Image(systemName: "gearshape.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AppTheme.textTertiary) + .frame(width: 26, height: 26) + .contentShape(Circle()) + } + .buttonStyle(.plain) + } + } +} + +struct VisualEffectBlur: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + } +} + +struct MenuBarGlyph: View { + let color: Color + + var body: some View { + Image(systemName: "gauge.with.dots.needle.33percent") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(color) + } +} diff --git a/packages/menubar/App/Views/UsageCard.swift b/packages/menubar/App/Views/UsageCard.swift new file mode 100644 index 0000000..3993d9b --- /dev/null +++ b/packages/menubar/App/Views/UsageCard.swift @@ -0,0 +1,160 @@ +import SwiftUI + +struct UsageCard: View { + let card: UsageCardState + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + headerRow + + ForEach(card.windows) { window in + WindowRow(window: window, gradient: card.kind.gradient) + } + + Text(card.footerText) + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundStyle(AppTheme.textTertiary) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AppTheme.cardFill) + ) + } + + private var headerRow: some View { + HStack(alignment: .center, spacing: 8) { + ServiceGlyph(kind: card.kind) + .frame(width: 22, height: 22) + + Text(card.serviceName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.textPrimary) + .lineLimit(1) + + Spacer() + + Text(card.headerDetail) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(AppTheme.textTertiary) + .lineLimit(1) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background( + Capsule() + .fill(Color.white.opacity(0.06)) + ) + } + } +} + +private struct WindowRow: View { + let window: UsageWindowState + let gradient: [Color] + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline) { + Text(window.label) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(AppTheme.textTertiary) + + Spacer() + + if let pct = window.remainingPercent { + Text("\(Int(pct.rounded()))%") + .font(.system(size: 15, weight: .medium, design: .rounded)) + .monospacedDigit() + .foregroundStyle(AppTheme.textPrimary) + } else { + Text("--%") + .font(.system(size: 15, weight: .medium, design: .rounded)) + .monospacedDigit() + .foregroundStyle(AppTheme.textTertiary) + } + } + + UsageProgressBar(progress: window.progress, gradient: gradient) + .frame(height: 5) + } + } +} + +private struct UsageProgressBar: View { + let progress: Double + let gradient: [Color] + + @State private var animatedProgress: Double = 0 + + var body: some View { + GeometryReader { geometry in + let fillWidth = geometry.size.width * CGFloat(max(0, min(1, animatedProgress))) + + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.06)) + + Capsule() + .fill( + LinearGradient( + colors: gradient, + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: max(fillWidth, fillWidth > 0 ? 4 : 0)) + } + } + .onAppear { + withAnimation(.easeOut(duration: 0.6)) { + animatedProgress = progress + } + } + .onChange(of: progress) { _, newValue in + withAnimation(.easeOut(duration: 0.5)) { + animatedProgress = newValue + } + } + } +} + +private struct ServiceGlyph: View { + let kind: UsageProviderKind + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + LinearGradient( + colors: kind.gradient.map { $0.opacity(0.25) }, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + if kind == .claude { + ZStack { + Circle() + .stroke(kind.markColor, lineWidth: 1.4) + Circle() + .trim(from: 0.05, to: 0.68) + .stroke(kind.markColor.opacity(0.45), style: StrokeStyle(lineWidth: 1.4, lineCap: .round)) + .rotationEffect(.degrees(-40)) + } + .padding(4) + } else { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .stroke(kind.markColor, lineWidth: 1.4) + .overlay { + VStack(spacing: 2) { + Capsule().fill(kind.markColor).frame(width: 6, height: 1.5) + Capsule().fill(kind.markColor).frame(width: 6, height: 1.5) + } + } + .padding(4) + } + } + } +} diff --git a/packages/menubar/package.json b/packages/menubar/package.json new file mode 100644 index 0000000..6cd0e31 --- /dev/null +++ b/packages/menubar/package.json @@ -0,0 +1,10 @@ +{ + "name": "@tokenleak/menubar", + "version": "2.1.0", + "private": true, + "scripts": { + "build": "bun ../../scripts/build-menubar-app.ts", + "test": "bun ../../scripts/check-menubar-app.ts", + "check": "bun ../../scripts/check-menubar-app.ts" + } +} diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts index 3fba00e..0433b65 100644 --- a/packages/registry/src/index.ts +++ b/packages/registry/src/index.ts @@ -14,9 +14,18 @@ export { export type { ModelPricing, CostBreakdown } from './models'; export type { IProvider } from './provider'; +export type { ClaudeQuotaSnapshot, CodexQuotaSnapshot, QuotaWindowSnapshot } from './providers/index'; export { ProviderRegistry } from './registry'; export { splitJsonlRecords } from './parsers/index'; -export { ClaudeCodeProvider, CodexProvider, CursorProvider, OpenCodeProvider, PiProvider } from './providers/index'; +export { + ClaudeCodeProvider, + CodexProvider, + CursorProvider, + OpenCodeProvider, + PiProvider, + extractClaudeQuotaSnapshot, + extractCodexQuotaSnapshot, +} from './providers/index'; export { CursorAuthError, getActiveCursorCredentials, diff --git a/packages/registry/src/providers/claude-rate-limits.test.ts b/packages/registry/src/providers/claude-rate-limits.test.ts new file mode 100644 index 0000000..37a6cb2 --- /dev/null +++ b/packages/registry/src/providers/claude-rate-limits.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { extractClaudeQuotaSnapshot } from './claude-rate-limits'; + +describe('extractClaudeQuotaSnapshot', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function writeSnapshot(dir: string, data: Record): string { + const path = join(dir, 'claude-rate-limits.json'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`); + return path; + } + + it('returns a valid snapshot with both windows', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 1, + source: 'claude-statusline', + capturedAt: '2026-03-28T10:00:00.000Z', + planType: 'max', + fiveHour: { usedPercent: 23, windowMinutes: 300, resetAt: '2026-03-28T15:00:00.000Z' }, + sevenDay: { usedPercent: 41, windowMinutes: 10080, resetAt: '2026-04-04T10:00:00.000Z' }, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).not.toBeNull(); + expect(snapshot?.provider).toBe('claude-code'); + expect(snapshot?.capturedAt).toBe('2026-03-28T10:00:00.000Z'); + expect(snapshot?.planType).toBe('max'); + expect(snapshot?.fiveHour?.usedPercent).toBe(23); + expect(snapshot?.fiveHour?.windowMinutes).toBe(300); + expect(snapshot?.sevenDay?.usedPercent).toBe(41); + expect(snapshot?.sevenDay?.windowMinutes).toBe(10080); + }); + + it('returns null when the file does not exist', () => { + const snapshot = extractClaudeQuotaSnapshot('/tmp/nonexistent-claude-rl.json'); + expect(snapshot).toBeNull(); + }); + + it('returns null for invalid JSON', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = join(dir, 'claude-rate-limits.json'); + writeFileSync(path, 'not json at all'); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).toBeNull(); + }); + + it('returns null when both windows are missing', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 1, + capturedAt: '2026-03-28T10:00:00.000Z', + fiveHour: null, + sevenDay: null, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).toBeNull(); + }); + + it('returns snapshot with only fiveHour present', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 1, + capturedAt: '2026-03-28T10:00:00.000Z', + planType: 'pro', + fiveHour: { usedPercent: 55, windowMinutes: 300, resetAt: '2026-03-28T15:00:00.000Z' }, + sevenDay: null, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).not.toBeNull(); + expect(snapshot?.fiveHour?.usedPercent).toBe(55); + expect(snapshot?.sevenDay).toBeNull(); + }); + + it('converts epoch resetAt to ISO string', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const epochSeconds = 1774900800; + const path = writeSnapshot(dir, { + schemaVersion: 1, + capturedAt: '2026-03-28T10:00:00.000Z', + fiveHour: { usedPercent: 10, windowMinutes: 300, resetAt: epochSeconds }, + sevenDay: null, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).not.toBeNull(); + expect(snapshot?.fiveHour?.resetAt).toBe(new Date(epochSeconds * 1000).toISOString()); + }); + + it('preserves ISO string resetAt as-is', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 1, + capturedAt: '2026-03-28T10:00:00.000Z', + fiveHour: { usedPercent: 10, windowMinutes: 300, resetAt: '2026-03-28T15:00:00.000Z' }, + sevenDay: null, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot?.fiveHour?.resetAt).toBe('2026-03-28T15:00:00.000Z'); + }); + + it('returns null for unsupported schema version', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 999, + capturedAt: '2026-03-28T10:00:00.000Z', + fiveHour: { usedPercent: 10, windowMinutes: 300, resetAt: null }, + sevenDay: null, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).toBeNull(); + }); + + it('handles snake_case field names from the bridge script', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 1, + capturedAt: '2026-03-28T10:00:00.000Z', + fiveHour: { used_percentage: 30, window_minutes: 300, resets_at: 1774900800 }, + sevenDay: { used_percent: 50, window_minutes: 10080, reset_at: '2026-04-04T10:00:00.000Z' }, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).not.toBeNull(); + expect(snapshot?.fiveHour?.usedPercent).toBe(30); + expect(snapshot?.sevenDay?.usedPercent).toBe(50); + }); +}); diff --git a/packages/registry/src/providers/claude-rate-limits.ts b/packages/registry/src/providers/claude-rate-limits.ts new file mode 100644 index 0000000..d038b50 --- /dev/null +++ b/packages/registry/src/providers/claude-rate-limits.ts @@ -0,0 +1,107 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import type { QuotaWindowSnapshot } from './codex-rate-limits'; + +export { type QuotaWindowSnapshot } from './codex-rate-limits'; + +const SCHEMA_VERSION = 1; + +export interface ClaudeQuotaSnapshot { + provider: 'claude-code'; + capturedAt: string; + planType: string | null; + fiveHour: QuotaWindowSnapshot | null; + sevenDay: QuotaWindowSnapshot | null; +} + +function resolveDefaultSnapshotPath(): string { + return join( + homedir(), + 'Library', + 'Application Support', + 'tokenleak', + 'menubar', + 'claude-rate-limits.json', + ); +} + +function toResetAtIso(value: unknown): string | null { + if (typeof value === 'string' && value.length > 0) { + return value; + } + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return new Date(value * 1000).toISOString(); + } + return null; +} + +function parseStoredWindow( + value: unknown, + fallbackMinutes: number, +): QuotaWindowSnapshot | null { + if (typeof value !== 'object' || value === null) { + return null; + } + + const record = value as Record; + const usedPercent = record['usedPercent'] ?? record['used_percent'] ?? record['used_percentage']; + const windowMinutes = record['windowMinutes'] ?? record['window_minutes'] ?? fallbackMinutes; + const resetAt = record['resetAt'] ?? record['reset_at'] ?? record['resets_at']; + + if (typeof usedPercent !== 'number') { + return null; + } + + return { + usedPercent, + windowMinutes: typeof windowMinutes === 'number' ? windowMinutes : fallbackMinutes, + resetAt: toResetAtIso(resetAt), + }; +} + +export function extractClaudeQuotaSnapshot( + snapshotPath: string = resolveDefaultSnapshotPath(), +): ClaudeQuotaSnapshot | null { + if (!existsSync(snapshotPath)) { + return null; + } + + let raw: unknown; + try { + raw = JSON.parse(readFileSync(snapshotPath, 'utf8')); + } catch { + return null; + } + + if (typeof raw !== 'object' || raw === null) { + return null; + } + + const root = raw as Record; + if (typeof root['schemaVersion'] === 'number' && root['schemaVersion'] > SCHEMA_VERSION) { + return null; + } + + const capturedAt = root['capturedAt']; + if (typeof capturedAt !== 'string' || capturedAt.length === 0) { + return null; + } + + const fiveHour = parseStoredWindow(root['fiveHour'], 300); + const sevenDay = parseStoredWindow(root['sevenDay'], 10080); + + if (!fiveHour && !sevenDay) { + return null; + } + + const planType = typeof root['planType'] === 'string' ? root['planType'] : null; + + return { + provider: 'claude-code', + capturedAt, + planType, + fiveHour, + sevenDay, + }; +} diff --git a/packages/registry/src/providers/codex-rate-limits.test.ts b/packages/registry/src/providers/codex-rate-limits.test.ts new file mode 100644 index 0000000..1e36b70 --- /dev/null +++ b/packages/registry/src/providers/codex-rate-limits.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { extractCodexQuotaSnapshot } from './codex-rate-limits'; + +function writeSession(root: string, relativePath: string, lines: string[]): void { + const fullPath = join(root, relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, `${lines.join('\n')}\n`); +} + +describe('extractCodexQuotaSnapshot', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns the newest non-null rate limits snapshot', async () => { + const root = mkdtempSync(join(tmpdir(), 'tokenleak-codex-quotas-')); + tempDirs.push(root); + + writeSession(root, '2026/03/21/session-a.jsonl', [ + JSON.stringify({ + timestamp: '2026-03-21T09:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: { + primary: { used_percent: 12, window_minutes: 300, resets_at: 1774184683 }, + secondary: { used_percent: 44, window_minutes: 10080, resets_at: 1774554212 }, + plan_type: 'plus', + }, + }, + }), + ]); + + writeSession(root, '2026/03/22/session-b.jsonl', [ + JSON.stringify({ + timestamp: '2026-03-22T09:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: null, + }, + }), + JSON.stringify({ + timestamp: '2026-03-22T10:15:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: { + primary: { used_percent: 18, window_minutes: 300, resets_at: 1774271083 }, + secondary: { used_percent: 51, window_minutes: 10080, resets_at: 1774637012 }, + plan_type: 'pro', + }, + }, + }), + ]); + + const snapshot = await extractCodexQuotaSnapshot(root); + + expect(snapshot).not.toBeNull(); + expect(snapshot?.capturedAt).toBe('2026-03-22T10:15:00.000Z'); + expect(snapshot?.planType).toBe('pro'); + expect(snapshot?.fiveHour?.usedPercent).toBe(18); + expect(snapshot?.fiveHour?.windowMinutes).toBe(300); + expect(snapshot?.sevenDay?.usedPercent).toBe(51); + expect(snapshot?.sevenDay?.windowMinutes).toBe(10080); + }); + + it('returns null when no usable rate limits exist', async () => { + const root = mkdtempSync(join(tmpdir(), 'tokenleak-codex-quotas-')); + tempDirs.push(root); + + writeSession(root, '2026/03/22/session.jsonl', [ + JSON.stringify({ + timestamp: '2026-03-22T10:15:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: null, + }, + }), + ]); + + const snapshot = await extractCodexQuotaSnapshot(root); + expect(snapshot).toBeNull(); + }); +}); diff --git a/packages/registry/src/providers/codex-rate-limits.ts b/packages/registry/src/providers/codex-rate-limits.ts new file mode 100644 index 0000000..753d9e9 --- /dev/null +++ b/packages/registry/src/providers/codex-rate-limits.ts @@ -0,0 +1,154 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { splitJsonlRecords } from '../parsers/jsonl-splitter'; + +function resolveDefaultSessionsDir(): string { + return join(process.env['CODEX_HOME'] ?? join(homedir(), '.codex'), 'sessions'); +} + +export interface QuotaWindowSnapshot { + usedPercent: number; + windowMinutes: number; + resetAt: string | null; +} + +export interface CodexQuotaSnapshot { + provider: 'codex'; + capturedAt: string; + planType: string | null; + fiveHour: QuotaWindowSnapshot | null; + sevenDay: QuotaWindowSnapshot | null; +} + +interface ParsedCodexQuotaSnapshot { + capturedAt: string; + planType: string | null; + fiveHour: QuotaWindowSnapshot | null; + sevenDay: QuotaWindowSnapshot | null; +} + +function collectJsonlFiles(dir: string): string[] { + if (!existsSync(dir)) { + return []; + } + + const files: string[] = []; + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry); + const stats = statSync(fullPath); + if (stats.isDirectory()) { + files.push(...collectJsonlFiles(fullPath)); + } else if (entry.endsWith('.jsonl')) { + files.push(fullPath); + } + } + + return files; +} + +function toResetAtIso(value: unknown): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + + return new Date(value * 1000).toISOString(); +} + +function parseQuotaWindow(value: unknown): QuotaWindowSnapshot | null { + if (typeof value !== 'object' || value === null) { + return null; + } + + const record = value as Record; + const usedPercent = record['used_percent']; + const windowMinutes = record['window_minutes']; + + if (typeof usedPercent !== 'number' || typeof windowMinutes !== 'number') { + return null; + } + + return { + usedPercent, + windowMinutes, + resetAt: toResetAtIso(record['resets_at']), + }; +} + +function parseQuotaSnapshot(record: unknown): ParsedCodexQuotaSnapshot | null { + if (typeof record !== 'object' || record === null) { + return null; + } + + const root = record as Record; + if (root['type'] !== 'event_msg') { + return null; + } + + const timestamp = root['timestamp']; + const payload = root['payload']; + if (typeof timestamp !== 'string' || typeof payload !== 'object' || payload === null) { + return null; + } + + const payloadRecord = payload as Record; + if (payloadRecord['type'] !== 'token_count') { + return null; + } + + const rateLimits = payloadRecord['rate_limits']; + if (typeof rateLimits !== 'object' || rateLimits === null) { + return null; + } + + const limits = rateLimits as Record; + const fiveHour = parseQuotaWindow(limits['primary']); + const sevenDay = parseQuotaWindow(limits['secondary']); + + if (!fiveHour && !sevenDay) { + return null; + } + + return { + capturedAt: timestamp, + planType: typeof limits['plan_type'] === 'string' ? limits['plan_type'] : null, + fiveHour, + sevenDay, + }; +} + +export async function extractCodexQuotaSnapshot( + baseDir: string = resolveDefaultSessionsDir(), +): Promise { + const files = collectJsonlFiles(baseDir); + let latest: ParsedCodexQuotaSnapshot | null = null; + + for (const file of files) { + try { + for await (const record of splitJsonlRecords(file)) { + const parsed = parseQuotaSnapshot(record); + if (!parsed) { + continue; + } + + if (!latest || parsed.capturedAt > latest.capturedAt) { + latest = parsed; + } + } + } catch { + continue; + } + } + + if (!latest) { + return null; + } + + return { + provider: 'codex', + capturedAt: latest.capturedAt, + planType: latest.planType, + fiveHour: latest.fiveHour, + sevenDay: latest.sevenDay, + }; +} diff --git a/packages/registry/src/providers/index.ts b/packages/registry/src/providers/index.ts index 580698a..49fd4b5 100644 --- a/packages/registry/src/providers/index.ts +++ b/packages/registry/src/providers/index.ts @@ -1,5 +1,9 @@ export { ClaudeCodeProvider } from './claude-code'; +export { extractClaudeQuotaSnapshot } from './claude-rate-limits'; +export type { ClaudeQuotaSnapshot } from './claude-rate-limits'; export { CodexProvider } from './codex'; +export { extractCodexQuotaSnapshot } from './codex-rate-limits'; +export type { CodexQuotaSnapshot, QuotaWindowSnapshot } from './codex-rate-limits'; export { CursorProvider } from './cursor'; export { OpenCodeProvider } from './open-code'; export { PiProvider } from './pi'; diff --git a/scripts/build-menubar-app.ts b/scripts/build-menubar-app.ts new file mode 100644 index 0000000..86132e0 --- /dev/null +++ b/scripts/build-menubar-app.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env bun +import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +const rootDir = resolve(import.meta.dir, '..'); +const sourceDir = join(rootDir, 'packages', 'menubar', 'App'); +const outputApp = join(rootDir, 'packages', 'menubar', 'dist', 'Tokenleak Usage.app'); +const outputExecutable = join(outputApp, 'Contents', 'MacOS', 'Tokenleak Usage'); +const infoPlist = join(outputApp, 'Contents', 'Info.plist'); +const cliPackage = (await Bun.file(join(rootDir, 'packages', 'cli', 'package.json')).json()) as { + version: string; +}; + +function collectSwiftFiles(dir: string): string[] { + const entries = readdirSync(dir, { withFileTypes: true }); + return entries + .flatMap((entry) => { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + return collectSwiftFiles(fullPath); + } + return entry.name.endsWith('.swift') ? [fullPath] : []; + }) + .sort(); +} + +if (process.platform !== 'darwin') { + console.log('Skipping menubar app build on non-macOS host.'); + process.exit(0); +} + +if (!existsSync(sourceDir)) { + console.error(`Missing source directory: ${sourceDir}`); + process.exit(1); +} + +const sourceFiles = collectSwiftFiles(sourceDir); +if (sourceFiles.length === 0) { + console.error(`No Swift sources found in: ${sourceDir}`); + process.exit(1); +} + +rmSync(outputApp, { recursive: true, force: true }); +mkdirSync(dirname(outputExecutable), { recursive: true }); + +const compile = Bun.spawnSync( + [ + '/usr/bin/swiftc', + ...sourceFiles, + '-o', + outputExecutable, + '-framework', + 'AppKit', + '-framework', + 'Foundation', + '-framework', + 'SwiftUI', + '-framework', + 'Security', + ], + { + cwd: rootDir, + stdout: 'inherit', + stderr: 'inherit', + }, +); + +if (compile.exitCode !== 0) { + process.exit(compile.exitCode ?? 1); +} + +const plist = ` + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Tokenleak Usage + CFBundleIdentifier + com.tokenleak.menubar + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Tokenleak Usage + CFBundlePackageType + APPL + CFBundleShortVersionString + ${cliPackage.version} + CFBundleVersion + ${cliPackage.version} + LSUIElement + + NSHighResolutionCapable + + + +`; + +writeFileSync(infoPlist, plist); +chmodSync(outputExecutable, 0o755); + +const sign = Bun.spawnSync(['/usr/bin/codesign', '--force', '--deep', '--sign', '-', outputApp], { + stdout: 'inherit', + stderr: 'inherit', +}); + +if (sign.exitCode !== 0) { + process.exit(sign.exitCode ?? 1); +} + +console.log(outputApp); diff --git a/scripts/check-menubar-app.ts b/scripts/check-menubar-app.ts new file mode 100644 index 0000000..5ea0edc --- /dev/null +++ b/scripts/check-menubar-app.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env bun +import { existsSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const rootDir = resolve(import.meta.dir, '..'); +const sourceDir = join(rootDir, 'packages', 'menubar', 'App'); + +function collectSwiftFiles(dir: string): string[] { + const entries = readdirSync(dir, { withFileTypes: true }); + return entries + .flatMap((entry) => { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + return collectSwiftFiles(fullPath); + } + return entry.name.endsWith('.swift') ? [fullPath] : []; + }) + .sort(); +} + +if (process.platform !== 'darwin') { + console.log('Skipping menubar app checks on non-macOS host.'); + process.exit(0); +} + +if (!existsSync(sourceDir)) { + console.error(`Missing source directory: ${sourceDir}`); + process.exit(1); +} + +const sourceFiles = collectSwiftFiles(sourceDir); +if (sourceFiles.length === 0) { + console.error(`No Swift sources found in: ${sourceDir}`); + process.exit(1); +} + +const proc = Bun.spawnSync( + [ + '/usr/bin/swiftc', + '-typecheck', + ...sourceFiles, + '-framework', + 'AppKit', + '-framework', + 'Foundation', + '-framework', + 'SwiftUI', + '-framework', + 'Security', + ], + { + cwd: rootDir, + stdout: 'inherit', + stderr: 'inherit', + }, +); + +process.exit(proc.exitCode ?? 0); diff --git a/scripts/package-menubar-app.ts b/scripts/package-menubar-app.ts new file mode 100644 index 0000000..aa2cad1 --- /dev/null +++ b/scripts/package-menubar-app.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env bun +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const rootDir = resolve(import.meta.dir, '..'); +const buildScript = join(rootDir, 'scripts', 'build-menubar-app.ts'); +const appPath = join(rootDir, 'packages', 'menubar', 'dist', 'Tokenleak Usage.app'); +const outDir = join(rootDir, 'dist-menubar'); +const zipPath = join(outDir, 'tokenleak-menubar-macos-universal.zip'); + +if (process.platform !== 'darwin') { + console.log('Skipping menubar packaging on non-macOS host.'); + process.exit(0); +} + +const build = Bun.spawnSync([process.execPath, buildScript], { + cwd: rootDir, + stdout: 'inherit', + stderr: 'inherit', +}); + +if (build.exitCode !== 0) { + process.exit(build.exitCode ?? 1); +} + +if (!existsSync(appPath)) { + console.error(`Built app bundle not found: ${appPath}`); + process.exit(1); +} + +mkdirSync(outDir, { recursive: true }); +rmSync(zipPath, { force: true }); + +const zip = Bun.spawnSync( + ['/usr/bin/ditto', '-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath], + { + cwd: rootDir, + stdout: 'inherit', + stderr: 'inherit', + }, +); + +if (zip.exitCode !== 0) { + process.exit(zip.exitCode ?? 1); +} + +console.log(zipPath);