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
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,57 @@

## [Не выпущено]

### Добавлено

- **Два аккаунта Claude в одной панели.** Claude Code различает аккаунты
конфиг-домом, и второй дом (`~/.claude-b` плюс `CLAUDE_CONFIG_DIR`) держит
свой токен, не выбивая первый. Как только каталог появляется, в заголовке
панели встают два знака Claude: выбранный горит фирменным оранжевым, второй
гаснет до серого, а кто из них кто — говорит подсказка с живым адресом
аккаунта из `claude auth status`. Выбор переживает перезапуск. У каждого
аккаунта свой лимит, снимок, подобранный бюджет, отсечка счёта и журнал
уведомлений — файлы второго лежат рядом с приставкой `secondary-`. Общими их
сделать было нельзя: обновление одного стирало бы снимок другого.
Каталога второго дома нет — нет и переключателя, всё работает как раньше.
- **Раздел «Аккаунты» на вкладке «Доступ»**: оба дома, путь каждого и кто в
него вошёл. «Дом не заведён», «не вошли» и адрес — три разных положения, и
склеивать их в одно «нет данных» нельзя: делать в них надо разное.
- **Флаг `--account=primary|secondary`** выбирает аккаунт, в том числе
для `--json`.

### Исправлено

- **Вкладка «Доступ» показывала «не вошли» у аккаунта, в котором вы работаете.**
Состояние дома выяснялось вызовом `claude auth status` с
`CLAUDE_CONFIG_DIR`, а эта переменная, выставленная даже в тот же самый путь
`~/.claude`, заставляет Claude Code отвечать `loggedIn: false`: ячейку
учётных данных выбирает не путь, а факт установки переменной. Теперь она
ставится только для нестандартного дома, а унаследованная из окружения
снимается — приложение могли запустить из оболочки, где она уже выставлена.
- **Второй аккаунт показывал «в этот аккаунт ещё не вошли», хотя вход был.**
Токен второго дома искали перебором записей Keychain со сверкой
`organizationUuid` внутри каждой — а Claude Code 2.1.251 это поле из записи
убрал: там остался один `claudeAiOauth` с токенами и тарифом. Сверять стало
не с чем, и подходящей записи не находилось никогда. Мешал и фильтр имён: он
брал только записи, кончающиеся на `-credentials`, а запись второго дома
называется `Claude Code-credentials-034e8c6f`. Теперь имя записи выводится из
пути конфиг-дома — так же, как его собирает сам Claude Code, — и формулу
сторожит тест с живым вектором. Промах формулы (сменится схема именования в
CLI) даст «нет доступа», а не чужой токен: имя зависит от дома, а не от
аккаунта.
- **Метка аккаунта, по которой замечают вход другим аккаунтом, снова
собирается.** Она складывалась из `organizationUuid` записи Keychain и вместе
с ним пропала — отсечка счёта перестала бы замечать смену аккаунта у обоих.
Организацию теперь называет `claude auth status` для этого дома. Форма метки
прежняя, так что накопленный счёт на месте: `2a420a73·pro` в `state.json`
осталось тем же значением.
- **Незнакомое значение `activeAccount` больше не стоит всего конфига.**
Разбор поля бросал исключение, а оно обрывало чтение файла целиком — человек
молча терял все свои настройки разом. Повод не выдуманный: сборка с Codex
писала в этот ключ `claude`, и такие файлы лежат на дисках. Теперь
незнакомый аккаунт стоит только самого себя и сводится к первому.


## [0.2.0] — 2026-08-23

### Добавлено
Expand Down
6 changes: 5 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ let package = Package(

.executableTarget(
name: "ClaudeWeekApp",
dependencies: ["ClaudeWeekCore"]
dependencies: ["ClaudeWeekCore"],
// Знак Claude в переключателе аккаунтов: подходящего SF Symbol у
// сервиса нет, а рисовать его в коде — держать вторую копию
// фирменного знака, которая разойдётся с настоящим.
resources: [.process("Assets")]
),

// XCTest и swift-testing без Xcode недоступны, поэтому проверки —
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,28 @@ Code сбрасывает каждым обновлением токена, и

---

## Два аккаунта

Аккаунтов у Claude Code может быть два, и панель показывает оба. Различает их
конфиг-дом: обычная установка живёт в `~/.claude`, второй заводится каталогом
`~/.claude-b` и переменной `CLAUDE_CONFIG_DIR` — у каждого свои транскрипты и
своя запись Keychain, поэтому оба токена держатся одновременно, и вход одним
не выбивает другой.

Как только второй каталог появляется, в заголовке панели встают два знака
Claude. Выбранный аккаунт горит фирменным оранжевым, второй гаснет до серого;
кто из них кто, говорит подсказка при наведении — там стоит живой адрес
аккаунта, а не порядковый номер. Выбор переживает перезапуск. У каждого
аккаунта свой лимит, свой снимок и своя отсечка счёта, и переключатель меняет
их все разом. В `--json` тот же выбор делает флаг `--account=primary|secondary`.

Вкладка **«Доступ»** в настройках показывает оба аккаунта: где лежит дом
каждого и кто в него вошёл. Ни одного каталога нет — переключателя тоже нет,
и программа работает ровно как раньше, с одним аккаунтом.

Про то, как завести второй дом, — в
[docs/USAGE.md](docs/USAGE.md#два-аккаунта).

## Смена аккаунта

Вошли другим аккаунтом — рабочим вместо домашнего — и счёт начинается заново.
Expand All @@ -238,7 +260,7 @@ Keychain с той, на которой ведётся счёт, и при ра

```bash
swift build # обе цели
swift run ClaudeWeekTests # 477 проверок: без сети, без UI, свой раннер
swift run ClaudeWeekTests # 541 проверка: без сети, без UI, свой раннер
./scripts/make-app.sh # dist/ClaudeWeek.app — бандл, ничего не устанавливая
ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg
```
Expand Down
113 changes: 113 additions & 0 deletions Sources/ClaudeWeekApp/AccountPicker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import AppKit
import SwiftUI
import ClaudeWeekCore

/// Два знака в заголовке панели — два аккаунта Claude. Выбор хранится в
/// конфиге, но сам вид получает только значение и действие: отдельного
/// локального состояния, способного разойтись с реально загруженным
/// аккаунтом, нет.
struct AccountPicker: View {
let selection: UsageAccount
/// Чем подписан каждый аккаунт в подсказке — адрес из `claude auth status`,
/// пока он не получен, порядковое название.
let titles: [UsageAccount: String]
let onSelect: (UsageAccount) -> Void

@Environment(\.strings) private var s

var body: some View {
HStack(spacing: 5) {
ForEach(UsageAccount.allCases, id: \.self) { account in
AccountButton(
account: account,
isSelected: account == selection,
title: titles[account] ?? account.fallbackTitle(s.lang),
onSelect: onSelect
)
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(s.pick("Аккаунт", "Account"))
// Заголовок собран по базовой линии текста, а у картинки она проходит
// по нижней кромке — знаки вставали над строкой. Выдаём за базовую
// линию собственный центр, поднятый на столько же, на сколько центр
// строки стоит над baseline: знаки встают ровно посередине надписи.
.alignmentGuide(.firstTextBaseline) {
$0[VerticalAlignment.center] + Theme.textCenterAboveBaseline
}
}
}

private struct AccountButton: View {
let account: UsageAccount
let isSelected: Bool
let title: String
let onSelect: (UsageAccount) -> Void

@Environment(\.palette) private var palette
@Environment(\.strings) private var s

var body: some View {
Button {
onSelect(account)
} label: {
// Ни подложки, ни рамки: в заголовке это знаки, а не органы
// управления. Что выбрано, говорит цвет; поле вокруг знака
// добавлено только ради попадания мышью.
providerIcon
.padding(2)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Системное кольцо фокуса рисует вокруг знака рамку, которой здесь
// быть не должно: после клика подсвечены оба аккаунта сразу.
.focusEffectDisabled()
.help(help)
.accessibilityLabel(help)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}

/// У Claude нет подходящего SF Symbol: знак лежит в ресурсах таргета как
/// шаблонный PNG — силуэт держит альфа-канал, цвета в файле нет. Красит
/// его `iconColor`, поэтому знак живёт в любой теме.
@ViewBuilder
private var providerIcon: some View {
if let url = Bundle.module.url(forResource: "Claude", withExtension: "png"),
let image = NSImage(contentsOf: url) {
// Шаблонным знак делает сама картинка: SwiftUI на macOS красит
// `Image(nsImage:)` по `foregroundStyle` только тогда, когда
// `isTemplate` стоит у NSImage, — одного `renderingMode` мало.
Image(nsImage: template(image))
.resizable()
.scaledToFit()
.foregroundStyle(iconColor)
.frame(width: 13, height: 13)
} else {
Image(systemName: "questionmark")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(palette.secondaryText.color)
.frame(width: 13, height: 13)
}
}

private func template(_ image: NSImage) -> NSImage {
image.isTemplate = true
return image
}

/// Знаки у обоих аккаунтов одинаковые — сервис-то один, — поэтому
/// выбранный различает цвет: он горит фирменным оранжевым, невыбранный
/// гаснет до приглушённого серого. Кто из них кто, говорит подсказка: там
/// стоит живой адрес аккаунта, а не порядковый номер.
private var iconColor: Color {
isSelected
? Color(red: 0.83, green: 0.42, blue: 0.30)
: palette.secondaryText.color.opacity(0.55)
}

private var help: String {
isSelected
? s.pick("Показан \(title)", "Showing \(title)")
: s.pick("Показать \(title)", "Show \(title)")
}
}
Binary file added Sources/ClaudeWeekApp/Assets/Claude.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 26 additions & 4 deletions Sources/ClaudeWeekApp/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ enum CLI {
ClaudeWeek запустить приложение в строке меню
ClaudeWeek --json напечатать состояние недели в JSON и выйти
ClaudeWeek --provider=X источник данных: official, local или auto
ClaudeWeek --account=X чей лимит: primary или secondary
ClaudeWeek --config=ПУТЬ свой файл конфигурации
ClaudeWeek --calibrate=N подогнать локальную оценку под официальные N %
(число берётся из /usage внутри Claude Code)
Expand All @@ -26,7 +27,9 @@ enum CLI {
/// Флаги без значения и префиксы флагов со значением. По ним же отличаем
/// опечатку от каталога у `--icon` и `--screenshot`: те не начинаются с «-».
static let flags = ["--help", "-h", "--verbose", "--json", "--icon", "--screenshot", "--update"]
static let flagPrefixes = ["--config=", "--provider=", "--calibrate=", "--lang="]
static let flagPrefixes = [
"--config=", "--provider=", "--account=", "--calibrate=", "--lang=",
]

static func isKnown(_ argument: String) -> Bool {
flags.contains(argument) || flagPrefixes.contains { argument.hasPrefix($0) }
Expand Down Expand Up @@ -264,15 +267,31 @@ enum CLI {
var snapshot: UsageSnapshot?
var failure: Error?
do {
snapshot = try await ResolvingProvider(config: config).fetch()
// Тем же путём, что и приложение: у аккаунта свой дом, свои
// транскрипты и своя запись Keychain, и `--json` обязан показывать
// ровно то же, что видно в строке меню.
let status = await AccountDirectory.shared.status(
of: AccountLocation(account: config.activeAccount, config: config)
)
snapshot = try await ResolvingProvider.forAccount(
config.activeAccount,
config: config,
status: status
).fetch()
} catch {
failure = error
}
let elapsed = Date().timeIntervalSince(started)

// Окно официального источника, а без него — рассчитанное по конфигу.
let window = snapshot?.window ?? WeekWindow(containing: now, config: config)
let local = LocalProvider(config: config)
let location = AccountLocation(account: config.activeAccount, config: config)
let local = LocalProvider(
config: config,
root: location.projectsRoot,
indexURL: location.indexURL,
stateURL: location.countingStateURL
)

// Локальный скан нужен и при официальном источнике: он даёт стоимость
// и диагностику обхода, которых в ответе API нет.
Expand All @@ -286,7 +305,10 @@ enum CLI {

// Бюджет мог быть подобран автоматически по официальному проценту —
// он лежит в кеше, а не в конфиге.
let budget = try? await local.budget(for: usage, override: Store.loadCache()?.weeklyBudget)
let budget = try? await local.budget(
for: usage,
override: Store.loadCache(from: location.cacheURL)?.weeklyBudget
)
var percent: Output.Percent?

if let snapshot {
Expand Down
13 changes: 13 additions & 0 deletions Sources/ClaudeWeekApp/PanelModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ final class PanelModel {
var snapshot: UsageSnapshot?
var status: PanelStatus = .loading
var isRefreshing = false
/// Чем подписан каждый аккаунт в подсказке переключателя — адрес из
/// `claude auth status`. Пусто, пока ответ не получен: подписи тогда
/// берутся порядковые.
var accountTitles: [UsageAccount: String] = [:]
/// Переключатель показываем, только когда второй дом заведён. Признак
/// вычисляет контроллер по файловой системе — держать под это настройку
/// значило бы завести вторую копию того же факта.
var showsAccountPicker = false
/// Почему смотреть не на что. Ставится там, где причина известна заранее
/// и точнее общего отказа: в аккаунт просто не вошли, и ждать данных
/// бессмысленно — серые заглушки читались бы как вечная загрузка.
var notice: String?
/// Тикает раз в минуту, чтобы «до сброса» не врало.
var now: Date = Date()

Expand Down Expand Up @@ -198,6 +210,7 @@ final class PanelModel {
/// свой, иначе демо-снимок «протухал» бы прямо на картинке для README.
func apply(_ snapshot: UsageSnapshot, at moment: Date = Date()) {
self.snapshot = snapshot
notice = nil
now = moment
let age = moment.timeIntervalSince(snapshot.fetchedAt)
status = age > PanelModel.freshFor ? .stale : .ready
Expand Down
Loading