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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
- [2026-07-23] an out-of-date dashboard says restart and refuses changes
- [2026-07-23] claude routing keeps the native login, fixes #15
- [2026-07-22] session reset time on analytics, fixes #9
- [2026-07-22] logout
- [2026-07-22] api keys and extra usage, fixes #10
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@
"post-commit": "bun x @rubriclab/package post-commit"
},
"type": "module",
"version": "0.0.56"
"version": "0.0.58"
}
8 changes: 7 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { proxyIdentity } from './proxy.ts'
import { createStateStore, type StateStore } from './storage.ts'
import { renderDashboard } from './ui.ts'
import { createMacOsKeychainVault } from './vault.ts'
import { availableUpdate, VERSION } from './version.ts'
import { availableUpdate, installedVersion, VERSION } from './version.ts'

const DaemonLockSchema = z
.object({
Expand Down Expand Up @@ -926,6 +926,12 @@ export async function runCli(rawArguments: readonly string[]): Promise<number> {
if (action === undefined) {
break
}
// A dashboard from before an update still runs the old code; never
// let it write config or credentials.
if ((await installedVersion()) !== VERSION) {
alert = 'this dashboard is out of date — quit and run tokenmaxx again'
continue
}
if (action.kind === 'relogin' || action.kind === 'login' || action.kind === 'loginApiKey') {
const cli = action.provider === 'openai' ? 'codex' : 'claude'
freshScreen(action.kind === 'loginApiKey' ? `add a ${cli} api key` : `sign in with ${cli}`)
Expand Down
90 changes: 89 additions & 1 deletion src/config-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { installCodexConfig, installStatus, uninstallCodexConfig } from './config-install.ts'
import {
installClaudeConfig,
installCodexConfig,
installStatus,
uninstallClaudeConfig,
uninstallCodexConfig
} from './config-install.ts'
import { applicationPaths } from './paths.ts'

const legacyBrokenConfig = `model = "gpt-5.6-sol"
Expand Down Expand Up @@ -109,3 +115,85 @@ describe('installCodexConfig', () => {
expect(parsed.model_provider).toBe('tokenmaxx')
})
})

interface ClaudeSettingsFile {
env?: Record<string, string>
[key: string]: unknown
}

async function writeClaudeSettings(settings: ClaudeSettingsFile): Promise<void> {
await writeFile(
join(process.env.CLAUDE_CONFIG_DIR ?? '', 'settings.json'),
JSON.stringify(settings, null, 2)
)
}

async function readClaudeSettings(): Promise<ClaudeSettingsFile> {
return JSON.parse(
await readFile(join(process.env.CLAUDE_CONFIG_DIR ?? '', 'settings.json'), 'utf8')
) as ClaudeSettingsFile
}

describe('installClaudeConfig', () => {
test('sets only the base URL so the native claude.ai login stays active', async () => {
await writeClaudeSettings({ model: 'fable[1m]' })
await installClaudeConfig(paths())
const settings = await readClaudeSettings()
expect(settings.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8459/anthropic')
expect(settings.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
expect(settings.model).toBe('fable[1m]')
})

test('an earlier install left the dummy token behind; reinstall clears it', async () => {
await writeClaudeSettings({
env: {
ANTHROPIC_AUTH_TOKEN: 'managed-by-tokenmaxx',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8459/anthropic',
OTHER: 'kept'
}
})
await installClaudeConfig(paths())
const settings = await readClaudeSettings()
expect(settings.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
expect(settings.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8459/anthropic')
expect(settings.env?.OTHER).toBe('kept')
})

test('a token the user set themselves is not touched', async () => {
await writeClaudeSettings({ env: { ANTHROPIC_AUTH_TOKEN: 'users-own-token' } })
await installClaudeConfig(paths())
const settings = await readClaudeSettings()
expect(settings.env?.ANTHROPIC_AUTH_TOKEN).toBe('users-own-token')
expect(settings.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8459/anthropic')
})

test('installStatus reports claude routing after install', async () => {
await installClaudeConfig(paths())
expect((await installStatus()).claudeRouted).toBe(true)
})

test('uninstall removes the routing but keeps a token the user set themselves', async () => {
await writeClaudeSettings({
env: { ANTHROPIC_AUTH_TOKEN: 'users-own-token' },
model: 'fable[1m]'
})
await installClaudeConfig(paths())
await uninstallClaudeConfig()
const settings = await readClaudeSettings()
expect(settings.env?.ANTHROPIC_BASE_URL).toBeUndefined()
expect(settings.env?.ANTHROPIC_AUTH_TOKEN).toBe('users-own-token')
expect(settings.model).toBe('fable[1m]')
})

test('uninstall after a legacy dummy-token install leaves no env behind', async () => {
await writeClaudeSettings({
env: {
ANTHROPIC_AUTH_TOKEN: 'managed-by-tokenmaxx',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8459/anthropic'
}
})
await uninstallClaudeConfig()
const settings = await readClaudeSettings()
expect(settings.env).toBeUndefined()
})
})
12 changes: 8 additions & 4 deletions src/config-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,11 @@ export async function installClaudeConfig(paths: ApplicationPaths): Promise<stri
settings = {}
}
}
settings.env = {
...settings.env,
ANTHROPIC_AUTH_TOKEN: dummyAuthToken,
ANTHROPIC_BASE_URL: proxyBaseUrl(paths, 'anthropic')
// Base URL only: any set ANTHROPIC_AUTH_TOKEN switches Claude Code off its
// claude.ai login, losing connectors and MCP; the proxy injects credentials itself.
settings.env = { ...settings.env, ANTHROPIC_BASE_URL: proxyBaseUrl(paths, 'anthropic') }
if (legacyDummyTokens.includes(settings.env.ANTHROPIC_AUTH_TOKEN ?? '')) {
delete settings.env.ANTHROPIC_AUTH_TOKEN
}
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 })
Expand Down Expand Up @@ -146,6 +147,9 @@ export async function uninstallClaudeConfig(): Promise<string | null> {
if (!managed) {
return null
}
if (ANTHROPIC_AUTH_TOKEN !== undefined && !legacyDummyTokens.includes(ANTHROPIC_AUTH_TOKEN)) {
rest.ANTHROPIC_AUTH_TOKEN = ANTHROPIC_AUTH_TOKEN
}
if (Object.keys(rest).length === 0) {
settings.env = undefined
} else {
Expand Down
31 changes: 28 additions & 3 deletions src/tui/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
requestResetCredits,
requestSwitch
} from '../ipc.ts'
import { availableUpdate } from '../version.ts'
import { availableUpdate, installedVersion, VERSION } from '../version.ts'
import { buildScenario } from './fixtures.ts'
import {
brailleArea,
Expand Down Expand Up @@ -1075,6 +1075,7 @@ interface ViewState {
resetConfirm: ResetConfirm | null
updateAvailable: string | null
updateDismissed: boolean
staleVersion: string | null
}

type DashboardAction =
Expand Down Expand Up @@ -1144,7 +1145,21 @@ function view(ctx: Ctx, analytics: AnalyticsSnapshot, rows: Row[], state: ViewSt
)
)
)
if (state.updateAvailable !== null && !state.updateDismissed) {
if (state.staleVersion !== null) {
children.push(
Box(
{ flexDirection: 'row', gap: 1, justifyContent: 'center', width: '100%' },
Text({
attributes: 1,
bg: rgb(ctx.theme.warn),
content: ` ⟳ v${state.staleVersion} is installed — this dashboard is out of date `,
fg: rgb(ctx.theme.bg)
}),
Text({ attributes: 1, bg: rgb(ctx.theme.selected), content: ' q ', fg: rgb(ctx.theme.fg) }),
Text({ content: 'quit, then run tokenmaxx', fg: rgb(ctx.theme.dim) })
)
)
} else if (state.updateAvailable !== null && !state.updateDismissed) {
children.push(
Box(
{ flexDirection: 'row', gap: 1, justifyContent: 'center', width: '100%' },
Expand Down Expand Up @@ -1237,6 +1252,7 @@ export async function runTuiDashboard(
resetConfirm: null,
selected: 0,
settingsSelected: 0,
staleVersion: live ? null : (process.env.TOKENMAXX_FAKE_STALE ?? null),
tab: 'accounts',
timeframeIndex: 2,
updateAvailable: live ? null : (process.env.TOKENMAXX_FAKE_UPDATE ?? null),
Expand Down Expand Up @@ -1486,7 +1502,15 @@ export async function runTuiDashboard(
await new Promise<void>(resolve => {
const tick = 250
const interval = live
? setInterval(() => void reload(false).catch(() => undefined), 2_000)
? setInterval(() => {
void reload(false).catch(() => undefined)
void installedVersion().then(version => {
if (version !== VERSION && state.staleVersion !== version) {
state.staleVersion = version
paint()
}
})
}, 2_000)
: fixture.timewarp > 0
? setInterval(() => {
simulatedNow += tick * fixture.timewarp
Expand Down Expand Up @@ -1572,6 +1596,7 @@ export async function runTuiDashboard(
finish()
} else if (
key.name === 'u' &&
state.staleVersion === null &&
state.updateAvailable !== null &&
!state.updateDismissed &&
live
Expand Down
6 changes: 6 additions & 0 deletions src/version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { expect, test } from 'bun:test'
import { installedVersion, VERSION } from './version.ts'

test('the version on disk matches the running build in a source checkout', async () => {
expect(await installedVersion()).toBe(VERSION)
})
13 changes: 13 additions & 0 deletions src/version.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import packageJson from '../package.json'

export const VERSION: string = packageJson.version

// The version on disk, which an update may have moved past this running process.
export async function installedVersion(): Promise<string> {
try {
const raw = await readFile(join(import.meta.dir, '..', 'package.json'), 'utf8')
const { version } = JSON.parse(raw) as { version?: unknown }
return typeof version === 'string' ? version : VERSION
} catch {
return VERSION
}
}

function parts(version: string): number[] {
return version.split('.').map(part => Number.parseInt(part, 10) || 0)
}
Expand Down
Loading