From d2d1caa90c4ed20ed3fd66330c190c84220c8e0f Mon Sep 17 00:00:00 2001 From: Greem4 Date: Mon, 31 Aug 2026 17:57:16 +0300 Subject: [PATCH 1/5] =?UTF-8?q?=D0=9F=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D0=BB?= =?UTF-8?q?=20=D0=B2=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8=20=D0=B4=D0=B2?= =?UTF-8?q?=D0=B0=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=B0=20Cla?= =?UTF-8?q?ude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аккаунты Claude Code различает конфиг-дом, и второй дом держит свой токен, не выбивая первый. Как только каталог появляется, в заголовке панели встают два знака Claude: выбранный горит оранжевым, второй гаснет до серого, а кто из них кто — говорит подсказка с адресом из `claude auth status`. Каталога нет — нет и переключателя, всё работает как раньше. У каждого аккаунта своё всё: лимит, снимок, подобранный бюджет, отсечка счёта и журнал уведомлений. Файлы второго лежат рядом с приставкой `secondary-`; общими их сделать было нельзя — обновление одного стирало бы снимок другого. Первому имена оставлены прежние: в них накопленный счёт. Имя записи Keychain второго дома Claude Code не публикует. Выводить его формулой значило бы закрепить догадку о чужой внутренней детали: она сменится с обновлением CLI, и виджет молча покажет цифры не того аккаунта. Поэтому имя не выводится, а находится по организации, которую назвал `claude auth status`, и совпадение проверяет машина. Аккаунту без входа достаётся SignedOutCredentials, а не nil: nil в ResolvingProvider означает «Keychain по умолчанию», то есть запись первого аккаунта. Заодно починил разбор конфига: незнакомое значение activeAccount бросало исключение, а оно обрывало чтение файла целиком — человек молча терял все настройки разом. Повод не выдуманный, сборка с Codex писала сюда `claude`. Проверок стало 534. Обновил README, docs/USAGE.md, docs/ARCHITECTURE.md и журнал; make-app.sh кладёт в бандл ресурсы SwiftPM — без них Bundle.module обрывается fatalError'ом ещё до отрисовки панели. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018rK6AqLE4HgSaXzqGJCRt2 --- CHANGELOG.md | 27 ++ Package.swift | 6 +- README.md | 24 +- Sources/ClaudeWeekApp/AccountPicker.swift | 113 ++++++++ Sources/ClaudeWeekApp/Assets/Claude.png | Bin 0 -> 764 bytes Sources/ClaudeWeekApp/CLI.swift | 30 ++- Sources/ClaudeWeekApp/PanelModel.swift | 13 + Sources/ClaudeWeekApp/PopoverView.swift | 22 ++ Sources/ClaudeWeekApp/SettingsView.swift | 50 ++++ Sources/ClaudeWeekApp/SettingsWindow.swift | 56 +++- .../ClaudeWeekApp/StatusItemController.swift | 243 +++++++++++++++-- Sources/ClaudeWeekApp/Theme.swift | 9 + Sources/ClaudeWeekApp/main.swift | 8 + Sources/ClaudeWeekCore/Accounts.swift | 247 ++++++++++++++++++ Sources/ClaudeWeekCore/Config.swift | 68 +++++ Sources/ClaudeWeekCore/Keychain.swift | 122 +++++++++ Sources/ClaudeWeekTests/AccountTests.swift | 166 ++++++++++++ Sources/ClaudeWeekTests/main.swift | 1 + docs/ARCHITECTURE.md | 15 +- docs/USAGE.md | 89 ++++++- scripts/make-app.sh | 24 +- 21 files changed, 1290 insertions(+), 43 deletions(-) create mode 100644 Sources/ClaudeWeekApp/AccountPicker.swift create mode 100644 Sources/ClaudeWeekApp/Assets/Claude.png create mode 100644 Sources/ClaudeWeekCore/Accounts.swift create mode 100644 Sources/ClaudeWeekTests/AccountTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d9249f2..3ac35e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,33 @@ ## [Не выпущено] +### Добавлено + +- **Два аккаунта Claude в одной панели.** Claude Code различает аккаунты + конфиг-домом, и второй дом (`~/.claude-b` плюс `CLAUDE_CONFIG_DIR`) держит + свой токен, не выбивая первый. Как только каталог появляется, в заголовке + панели встают два знака Claude: выбранный горит фирменным оранжевым, второй + гаснет до серого, а кто из них кто — говорит подсказка с живым адресом + аккаунта из `claude auth status`. Выбор переживает перезапуск. У каждого + аккаунта свой лимит, снимок, подобранный бюджет, отсечка счёта и журнал + уведомлений — файлы второго лежат рядом с приставкой `secondary-`. Общими их + сделать было нельзя: обновление одного стирало бы снимок другого. + Каталога второго дома нет — нет и переключателя, всё работает как раньше. +- **Раздел «Аккаунты» на вкладке «Доступ»**: оба дома, путь каждого и кто в + него вошёл. «Дом не заведён», «не вошли» и адрес — три разных положения, и + склеивать их в одно «нет данных» нельзя: делать в них надо разное. +- **Флаг `--account=primary|secondary`** выбирает аккаунт, в том числе + для `--json`. + +### Исправлено + +- **Незнакомое значение `activeAccount` больше не стоит всего конфига.** + Разбор поля бросал исключение, а оно обрывало чтение файла целиком — человек + молча терял все свои настройки разом. Повод не выдуманный: сборка с Codex + писала в этот ключ `claude`, и такие файлы лежат на дисках. Теперь + незнакомый аккаунт стоит только самого себя и сводится к первому. + + ## [0.2.0] — 2026-08-23 ### Добавлено diff --git a/Package.swift b/Package.swift index be299ca..23f9093 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,11 @@ let package = Package( .executableTarget( name: "ClaudeWeekApp", - dependencies: ["ClaudeWeekCore"] + dependencies: ["ClaudeWeekCore"], + // Знак Claude в переключателе аккаунтов: подходящего SF Symbol у + // сервиса нет, а рисовать его в коде — держать вторую копию + // фирменного знака, которая разойдётся с настоящим. + resources: [.process("Assets")] ), // XCTest и swift-testing без Xcode недоступны, поэтому проверки — diff --git a/README.md b/README.md index 5914f03..8a80936 100644 --- a/README.md +++ b/README.md @@ -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#два-аккаунта). + ## Смена аккаунта Вошли другим аккаунтом — рабочим вместо домашнего — и счёт начинается заново. @@ -238,7 +260,7 @@ Keychain с той, на которой ведётся счёт, и при ра ```bash swift build # обе цели -swift run ClaudeWeekTests # 477 проверок: без сети, без UI, свой раннер +swift run ClaudeWeekTests # 534 проверки: без сети, без UI, свой раннер ./scripts/make-app.sh # dist/ClaudeWeek.app — бандл, ничего не устанавливая ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg ``` diff --git a/Sources/ClaudeWeekApp/AccountPicker.swift b/Sources/ClaudeWeekApp/AccountPicker.swift new file mode 100644 index 0000000..e6ad47d --- /dev/null +++ b/Sources/ClaudeWeekApp/AccountPicker.swift @@ -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)") + } +} diff --git a/Sources/ClaudeWeekApp/Assets/Claude.png b/Sources/ClaudeWeekApp/Assets/Claude.png new file mode 100644 index 0000000000000000000000000000000000000000..7006ffd785c934106c5d98906f4e27c3e532be2b GIT binary patch literal 764 zcmVGpE^7k$EUI5VA5-`wz_XP-IyKYOizt$hN40W%!O@uE~b z7iYu-kNcBFae`-i;8$>u_$>a2&0@IgUV*3;=S6OR2~H6$&cAV2%yivbARdW}VvK7( zotK%^8^JWOUwje2#a7oY7mKIjm>9%dme?*Hi+z3tt3|mO%Jp>7#4clE7V|5`FL9Xb zkf?1GMDn|Gb3=)cOqFpqK)gn(^ zV6j_bHiDMlO^9`H-$b{iVwxQC_LT)*i!FrOcG*SVtW<7_sCdh7I}i`0>Qb}$T=HXK zYZFIts*QW_iSXO#*SP@x$4zZ`}z%_ASoW%eS#0z%S?bM&*wkV@4g1B_PSZDty6U-1r zqMjW!laq3sXU3`#*C~bX;uZOe^``I(*hL$q5Ei4cy3H-bxsCj-W5;{NPQqBl$>B?I zJPVc4yfX=H44Y@Ma04ev31+$@meK}WIi}K6?KByPHPc_M35+A3Q5>9!sVw|Q#8^(6 zdZLw*UcXq_5BLg61qIs+#HUmNZfCWDE=LNY2IE` u^SM#!aW6>Y?pMJurBRx~GXv*)JMb5I6P)LPQMZc#0000 Void)? = nil) { + let config = model.config + Task { [weak self] in + var found: [UsageAccount: AccountStatus] = [:] + for account in UsageAccount.allCases { + let location = AccountLocation(account: account, config: config) + guard location.exists else { continue } + found[account] = await AccountDirectory.shared.status(of: location) + } + guard let self else { return } + adopt(found) + act?() + } + } + + private func adopt(_ statuses: [UsageAccount: AccountStatus]) { + accountStatuses = statuses + model.showsAccountPicker = AccountLocation(account: .secondary, config: model.config).exists + model.accountTitles = UsageAccount.allCases.reduce(into: [:]) { titles, account in + let fallback = account.fallbackTitle(s.lang) + titles[account] = statuses[account]?.title(fallback: fallback) ?? fallback + } + provider = Self.makeProvider(config: model.config, status: statuses[model.config.activeAccount]) + render() + } + + private static func makeProvider(config: Config, status: AccountStatus?) -> any UsageProvider { + ResolvingProvider.forAccount( + config.activeAccount, + config: config, + status: status ?? .signedOut + ) + } + + /// Меняет аккаунт панели целиком. Прошлый снимок запоминаем, чтобы + /// обратный щелчок был мгновенным, а отсутствующие данные не подменяем + /// цифрами другого аккаунта даже на время нового запроса. + private func selectAccount(_ account: UsageAccount) { + let previous = model.config.activeAccount + guard account != previous else { return } + + if let snapshot = model.snapshot { + snapshots[previous] = snapshot + } + + refreshGeneration += 1 + model.isRefreshing = false + model.expandsWeek = false + model.showsModels = false + model.notice = nil + + var config = model.config + config.activeAccount = account + model.config = config + provider = Self.makeProvider(config: config, status: accountStatuses[account]) + + let now = Date() + if let snapshot = snapshots[account], snapshot.window.contains(now) { + model.apply(snapshot, at: now) + } else { + snapshots[account] = nil + model.snapshot = nil + model.status = .loading + model.now = now + } + + settings?.adopt(config) + queueConfigWrite(config) + render() + scheduleResetRefresh() refresh() } @@ -67,7 +159,9 @@ final class StatusItemController: NSObject { /// секунду-другую, и всё это время строка меню иначе висела бы пустой. /// Возраст подписывает сама модель — правило свежести одно на всех. private func restoreFromCache() { - guard let cache = Store.loadCache(), cache.isFresh(at: Date()) else { return } + let location = AccountLocation(account: model.config.activeAccount, config: model.config) + guard let cache = Store.loadCache(from: location.cacheURL), + cache.isFresh(at: Date()) else { return } let snapshot = cache.snapshot(config: model.config) model.apply(snapshot) // Кеш — такой же повод сказать про лимит, как свежий ответ: машину @@ -102,6 +196,7 @@ final class StatusItemController: NSObject { model: model, update: update, onRefresh: { [weak self] in self?.refresh() }, + onAccountChange: { [weak self] account in self?.selectAccount(account) }, onSettings: { [weak self] in self?.openSettings() }, onQuit: { NSApp.terminate(nil) } ) @@ -281,12 +376,17 @@ final class StatusItemController: NSObject { dropdown.pin(from: button) } - private func applyFromSettings(_ config: Config) { + private func applyFromSettings(_ incoming: Config) { + // Окно настроек аккаунтом не управляет. Пока оно открыто, переключатель + // панели мог сработать, и старая копия конфига не должна его вернуть. + var config = incoming + config.activeAccount = model.config.activeAccount let providerChanged = config.provider != model.config.provider || config.timeZone != model.config.timeZone || config.weekStart != model.config.weekStart || config.workHours != model.config.workHours || config.weeklyBudget != model.config.weeklyBudget + || config.accounts != model.config.accounts // Интервал живёт в таймере, а не в провайдере: не перезапустив таймер, // новый интервал ждал бы перезапуска приложения. let intervalChanged = config.refreshInterval != model.config.refreshInterval @@ -301,8 +401,26 @@ final class StatusItemController: NSObject { render() if notificationsEnabled { notifications.apply(config.notifications) } - // Ползунок шлёт по десятку изменений в секунду — на диск ходим - // с задержкой, применяя к панели каждое сразу. + queueConfigWrite(config) + + guard providerChanged else { + if intervalChanged { startTimers() } + return + } + refreshGeneration += 1 + model.isRefreshing = false + // Дом аккаунта мог поменяться — тогда и вошедший в него другой, и + // прежний ответ `auth status` про него больше ничего не значит. + loadAccountStatuses { [weak self] in + self?.startTimers() + self?.refresh() + } + } + + /// Ползунок шлёт по десятку изменений в секунду — на диск ходим с + /// задержкой, применяя к панели каждое сразу. Тем же путём сохраняется и + /// выбранный аккаунт, поэтому конкурирующих записей конфига нет. + private func queueConfigWrite(_ config: Config) { saveTask?.cancel() pendingConfigWrite = config saveTask = Task { [weak self] in @@ -310,14 +428,6 @@ final class StatusItemController: NSObject { guard !Task.isCancelled else { return } self?.flushConfigWrite() } - - guard providerChanged else { - if intervalChanged { startTimers() } - return - } - provider = ResolvingProvider(config: config) - startTimers() - refresh() } /// Пишет отложенную правку немедленно, если она ещё не легла на диск. @@ -344,13 +454,30 @@ final class StatusItemController: NSObject { /// отсечку он перечитывает на каждом обходе транскриптов, а вот показать /// панели старый снимок после сброса было бы прямым враньём. private func resetCounting() { - let account = (try? KeychainCredentials().load())?.accountMark + let active = model.config.activeAccount + let location = AccountLocation(account: active, config: model.config) + // Метку берём у того аккаунта, чей счёт сбрасываем, а не у первого: + // иначе отсечка второго подписалась бы чужой организацией и на + // следующем же обходе сочлась за смену аккаунта. + let mark = ResolvingProvider.credentials( + for: active, + config: model.config, + status: accountStatuses[active] ?? .signedOut + ) + let account = (try? mark.load())?.accountMark do { - try Store.resetCounting(at: Date(), account: account) + try Store.resetCounting( + at: Date(), + account: account, + stateURL: location.countingStateURL, + cacheURL: location.cacheURL, + alertsURL: location.alertsURL + ) } catch { Log.warn("не смог начать счёт заново: \(error)") return } + snapshots[active] = nil model.snapshot = nil render() refresh() @@ -362,7 +489,17 @@ final class StatusItemController: NSObject { var config = config config.provider = .official do { - let snapshot = try await ResolvingProvider(config: config).fetch() + // Проверяем тот аккаунт, что показан в панели: кнопка отвечает на + // «а ходит ли оно по моему ключу», и ответ про соседний аккаунт + // был бы хуже молчания. + let status = await AccountDirectory.shared.status( + of: AccountLocation(account: config.activeAccount, config: config) + ) + let snapshot = try await ResolvingProvider.forAccount( + config.activeAccount, + config: config, + status: status + ).fetch() // Метод статический, окружения у него нет — язык берём из того же // конфига, с которым проверяют доступ. let spent = Formatting.percent(snapshot.usedPercent) @@ -512,14 +649,34 @@ final class StatusItemController: NSObject { guard config != model.config else { return } Log.info("конфиг изменился, применяю") let notificationsEnabled = config.notifications.enabled && !model.config.notifications.enabled + let previousAccount = model.config.activeAccount + if let snapshot = model.snapshot { + snapshots[previousAccount] = snapshot + } model.config = config + if config.activeAccount != previousAccount { + let now = Date() + model.notice = nil + if let snapshot = snapshots[config.activeAccount], snapshot.window.contains(now) { + model.apply(snapshot, at: now) + } else { + model.snapshot = nil + model.status = .loading + model.now = now + } + } update.strings = config.strings if notificationsEnabled { notifications.apply(config.notifications) } settings?.adopt(config) applyAppearance() - provider = ResolvingProvider(config: config) - startTimers() - refresh() + refreshGeneration += 1 + model.isRefreshing = false + loadAccountStatuses { [weak self] in + self?.startTimers() + self?.render() + self?.scheduleResetRefresh() + self?.refresh() + } } // MARK: Обновление @@ -528,22 +685,56 @@ final class StatusItemController: NSObject { guard !model.isRefreshing else { return } model.isRefreshing = true let provider = provider + let account = model.config.activeAccount + let generation = refreshGeneration Task { [weak self] in + let result: Result do { - let snapshot = try await provider.fetch() - self?.model.apply(snapshot) - if let self { notifications.consider(snapshot, config: model.config) } + result = .success(try await provider.fetch()) } catch { + result = .failure(error) + } + + // Пока запрос шёл, могли переключить аккаунт или сменить конфиг. + // Отменять сетевой вызов ради этого не нужно — достаточно не + // принимать его ответ. + guard let self, + generation == refreshGeneration, + account == model.config.activeAccount else { return } + + switch result { + case .success(let snapshot): + snapshots[account] = snapshot + model.apply(snapshot) + notifications.consider(snapshot, config: model.config) + case .failure(let error): Log.warn("не смог обновить данные: \(error)") - self?.model.apply(error: error) + model.apply(error: error) + // Отказ у аккаунта, в который не входили, — не поломка, а + // ровно то, чего и следовало ждать. Общее «нет данных» тут + // прячет причину, которую человек может устранить за минуту. + if accountStatuses[account]?.loggedIn == false, model.snapshot == nil { + model.notice = signInNotice(for: account) + } } - self?.model.isRefreshing = false - self?.render() + model.isRefreshing = false + render() // Окно снимка могло смениться — переставляем будильник под него. // Отсюда же он ставится и в первый раз: `init` заканчивается // обновлением, а до его ответа окна может не быть вовсе. - self?.scheduleResetRefresh() + scheduleResetRefresh() } } + + /// Что делать, чтобы аккаунт заработал. Путь дома называем прямо: он же + /// уходит в `CLAUDE_CONFIG_DIR`, и подставлять его человеку по памяти — + /// лишний шаг к ошибке. + private func signInNotice(for account: UsageAccount) -> String { + let home = AccountLocation(account: account, config: model.config).home.path + return s.pick( + "В этот аккаунт ещё не вошли. Запустите Claude Code с CLAUDE_CONFIG_DIR=\(home) и выполните /login.", + "This account is not signed in yet. Start Claude Code with CLAUDE_CONFIG_DIR=\(home) and run /login." + ) + } } diff --git a/Sources/ClaudeWeekApp/Theme.swift b/Sources/ClaudeWeekApp/Theme.swift index 9c553d4..23c087d 100644 --- a/Sources/ClaudeWeekApp/Theme.swift +++ b/Sources/ClaudeWeekApp/Theme.swift @@ -261,6 +261,15 @@ enum Theme { /// Сноски — на два кегля мельче основного текста, как в меню. static let captionFontSize = menuFontSize - 2 + /// Насколько центр строки текста стоит выше её базовой линии — полусумма + /// подъёма и спуска шрифта меню (`descender` отрицательный). Нужна там, + /// где по базовой линии выравнивают не текст: картинка отдаёт за базовую + /// линию свою нижнюю кромку и без поправки встаёт над строкой. + static let textCenterAboveBaseline: CGFloat = { + let font = NSFont.menuFont(ofSize: 0) + return (font.ascender + font.descender) / 2 + }() + static let titleFont = Font.system(size: menuFontSize, weight: .semibold).monospacedDigit() static let dayFont = Font.system(size: menuFontSize).monospacedDigit() /// Подпись текущих суток: тот же кегль, но полужирная. Одного цвета ей не diff --git a/Sources/ClaudeWeekApp/main.swift b/Sources/ClaudeWeekApp/main.swift index 0bb55b3..1f288df 100644 --- a/Sources/ClaudeWeekApp/main.swift +++ b/Sources/ClaudeWeekApp/main.swift @@ -31,6 +31,14 @@ if let raw = arguments.first(where: { $0.hasPrefix("--provider=") })? config.provider = choice } +if let raw = arguments.first(where: { $0.hasPrefix("--account=") })? + .dropFirst("--account=".count) { + guard let choice = UsageAccount(rawValue: String(raw)) else { + exit(CLI.complain("неизвестный аккаунт «\(raw)»: бывают primary и secondary")) + } + config.activeAccount = choice +} + // Язык из аргумента перекрывает конфиг и на один запуск, и на снимки: так // английские картинки для документации снимаются, ничего не переключая руками. if let raw = arguments.first(where: { $0.hasPrefix("--lang=") })? diff --git a/Sources/ClaudeWeekCore/Accounts.swift b/Sources/ClaudeWeekCore/Accounts.swift new file mode 100644 index 0000000..0cf2d54 --- /dev/null +++ b/Sources/ClaudeWeekCore/Accounts.swift @@ -0,0 +1,247 @@ +import Foundation + +/// Разложенные пути одного аккаунта: где его транскрипты и где файлы, которыми +/// ClaudeWeek ведёт по нему счёт. +/// +/// Состояние аккаунтов лежит порознь намеренно. Общий `cache.json` позволил бы +/// последнему обновлению одного аккаунта стереть снимок другого, а общий +/// `state.json` — отдать отсечку счёта не тому лимиту. Первый аккаунт при этом +/// сохраняет прежние имена файлов: у него уже есть накопленный счёт, и переезд +/// на новое имя обнулил бы его на ровном месте. +public struct AccountLocation: Sendable, Equatable { + public let account: UsageAccount + /// Конфиг-дом Claude Code — то же, что уходит в `CLAUDE_CONFIG_DIR`. + public let home: URL + + public init(account: UsageAccount, home: URL) { + self.account = account + self.home = home + } + + public init(account: UsageAccount, config: Config) { + self.init(account: account, home: AccountLocation.expand(config.accounts.home(account))) + } + + /// Каталог транскриптов — то, по чему считает `LocalProvider`. + public var projectsRoot: URL { + home.appendingPathComponent("projects", isDirectory: true) + } + + /// Запасной путь к кредам: на части установок Claude Code кладёт их файлом, + /// а не в Keychain. Файл лежит в самом доме, поэтому у каждого аккаунта он + /// свой без всяких настроек. + public var credentialsFileURL: URL { + home.appendingPathComponent(".credentials.json") + } + + /// Дом существует. Единственный признак, по которому решается, показывать + /// ли переключатель: заводить для этого отдельный флаг в конфиге значило бы + /// держать вторую копию того же факта, и она бы разошлась. + public var exists: Bool { + var isDirectory: ObjCBool = false + let found = FileManager.default.fileExists(atPath: home.path, isDirectory: &isDirectory) + return found && isDirectory.boolValue + } + + private var filePrefix: String { + switch account { + case .primary: "" + case .secondary: "secondary-" + } + } + + private func stateFile(_ name: String) -> URL { + Store.directory.appendingPathComponent(filePrefix + name) + } + + public var cacheURL: URL { stateFile("cache.json") } + public var alertsURL: URL { stateFile("alerts.json") } + public var countingStateURL: URL { stateFile("state.json") } + public var indexURL: URL { stateFile("index.json") } + + /// `~` в пути конфига раскрываем сами: путь пишет человек, а `URL` тильду + /// не понимает и молча заводит каталог с именем «~». + public static func expand(_ path: String) -> URL { + let trimmed = path.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return URL(fileURLWithPath: NSHomeDirectory()) } + return URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath) + } +} + +/// Кто вошёл в этот конфиг-дом. Отвечает сам Claude Code — `claude auth status` +/// печатает JSON, и другого источника, знающего про дом, у нас нет: по записи +/// Keychain дом не определить, а по транскриптам аккаунт не различить вовсе. +public struct AccountStatus: Sendable, Equatable { + public let loggedIn: Bool + public let email: String? + public let organizationId: String? + public let subscriptionType: String? + + public init( + loggedIn: Bool, + email: String? = nil, + organizationId: String? = nil, + subscriptionType: String? = nil + ) { + self.loggedIn = loggedIn + self.email = email + self.organizationId = organizationId + self.subscriptionType = subscriptionType + } + + public static let signedOut = AccountStatus(loggedIn: false) + + /// Чем аккаунт подписывают в интерфейсе: адрес короче и понятнее UUID, + /// а при его отсутствии — хотя бы тариф. + public func title(fallback: String) -> String { + if let email, !email.isEmpty { return email } + if let subscriptionType, !subscriptionType.isEmpty { return subscriptionType } + return fallback + } +} + +/// Спрашивает Claude Code, кто вошёл в указанный дом. +/// +/// Запускается процесс, поэтому ответ кешируется: панель обновляется раз в +/// минуту, а вход и выход случаются раз в месяц. Кеш сбрасывает тот, кто знает +/// повод — окно настроек по кнопке проверки и переключение аккаунта. +public actor AccountDirectory { + public static let shared = AccountDirectory() + + private var cache: [URL: AccountStatus] = [:] + + public init() {} + + public func status(of location: AccountLocation) async -> AccountStatus { + if let known = cache[location.home] { return known } + let status = AccountDirectory.read(home: location.home) + cache[location.home] = status + return status + } + + public func forget() { + cache.removeAll() + } + + /// Ищем исполняемый файл там же, куда его кладёт установщик Claude Code, и + /// только потом в PATH: у приложения из Dock окружение обрезанное, и на + /// PATH там рассчитывать нельзя. + static func executable() -> URL? { + var candidates = [ + URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".local/bin/claude"), + URL(fileURLWithPath: "/opt/homebrew/bin/claude"), + URL(fileURLWithPath: "/usr/local/bin/claude"), + ] + if let path = ProcessInfo.processInfo.environment["PATH"] { + candidates += path.split(separator: ":").map { + URL(fileURLWithPath: String($0)).appendingPathComponent("claude") + } + } + return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) } + } + + static func read(home: URL) -> AccountStatus { + guard let executable = executable() else { + Log.debug("не нашёл исполняемый файл claude — состояние аккаунта неизвестно") + return .signedOut + } + + let process = Process() + process.executableURL = executable + process.arguments = ["auth", "status"] + var environment = ProcessInfo.processInfo.environment + environment["CLAUDE_CONFIG_DIR"] = home.path + process.environment = environment + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + Log.debug("claude auth status не запустился: \(error.localizedDescription)") + return .signedOut + } + + // Панель обновляется в фоне; повисший на чём-нибудь процесс не должен + // забирать её с собой. + let timeout = DispatchWorkItem { + if process.isRunning { process.terminate() } + } + DispatchQueue.global().asyncAfter(deadline: .now() + 10, execute: timeout) + let output = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + timeout.cancel() + + guard process.terminationStatus == 0 else { return .signedOut } + return parse(output) + } + + /// Форма ответа — не публичный контракт, поэтому недостающие поля здесь + /// норма, а не ошибка: без адреса аккаунт подпишется тарифом, без тарифа — + /// номером, и переключатель продолжит работать. + public static func parse(_ data: Data) -> AccountStatus { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .signedOut + } + guard root["loggedIn"] as? Bool == true else { return .signedOut } + return AccountStatus( + loggedIn: true, + email: root["email"] as? String, + organizationId: root["orgId"] as? String, + subscriptionType: root["subscriptionType"] as? String + ) + } +} + +public extension ResolvingProvider { + /// Провайдер для одного аккаунта: свои транскрипты, свои файлы счёта, свои + /// креды. + /// + /// Аккаунту без входа даём `SignedOutCredentials`, а не `nil`: `nil` здесь + /// значит «Keychain по умолчанию», и второй дом молча показал бы цифры + /// первого. + static func forAccount( + _ account: UsageAccount, + config: Config, + status: AccountStatus + ) -> ResolvingProvider { + let location = AccountLocation(account: account, config: config) + let credentials = credentials(for: account, config: config, status: status) + + return ResolvingProvider( + config: config, + credentials: credentials, + cacheURL: location.cacheURL, + localRoot: location.projectsRoot, + indexURL: location.indexURL, + stateURL: location.countingStateURL, + alertsURL: location.alertsURL + ) + } + + /// Откуда берётся токен аккаунта. Отдельно от фабрики, потому что тем же + /// ответом подписывается отсечка счёта: спрашивать «чей это аккаунт» надо + /// у того же источника, из которого потом читают токен. + static func credentials( + for account: UsageAccount, + config: Config, + status: AccountStatus + ) -> CredentialsSource { + let location = AccountLocation(account: account, config: config) + switch account { + case .primary: + // Первый аккаунт живёт в записи с известным именем — той самой, + // которую Claude Code завёл при установке. + return KeychainCredentials(fileURL: location.credentialsFileURL) + case .secondary: + return status.organizationId.map { + ResolvedKeychainCredentials( + organizationId: $0, + fileURL: location.credentialsFileURL + ) as CredentialsSource + } ?? SignedOutCredentials() + } + } +} diff --git a/Sources/ClaudeWeekCore/Config.swift b/Sources/ClaudeWeekCore/Config.swift index bbfc7a1..cf333be 100644 --- a/Sources/ClaudeWeekCore/Config.swift +++ b/Sources/ClaudeWeekCore/Config.swift @@ -6,6 +6,57 @@ public enum ProviderPreference: String, Codable, Sendable, CaseIterable { case auto } +/// Какой из двух аккаунтов Claude сейчас показан в панели. Это не выбор +/// источника внутри аккаунта: `provider` по-прежнему решает, брать процент у +/// сервера или считать локально. Здесь переключаются два независимых входа в +/// Claude Code — у каждого свой конфиг-дом, своя запись Keychain, свои +/// транскрипты и свой недельный лимит. +public enum UsageAccount: String, Codable, Sendable, CaseIterable, Hashable { + case primary + case secondary + + /// Подпись, когда живого адреса аккаунта ещё нет: до входа спрашивать + /// `claude auth status` не о чем, а назвать аккаунт в подсказке надо. + public func fallbackTitle(_ lang: Lang) -> String { + let l = L10n(lang) + switch self { + case .primary: return l.pick("Первый аккаунт", "First account") + case .secondary: return l.pick("Второй аккаунт", "Second account") + } + } +} + +/// Где живут аккаунты Claude Code. +/// +/// Конфиг-дом — единственное, что про аккаунт нужно знать: из него берутся и +/// транскрипты, и запись Keychain с токеном, и ответ `claude auth status`. +/// Имя записи Keychain здесь не хранится намеренно — оно выводилось бы вторым +/// экземпляром той же правды и разошлось бы с домом молча; как его находят, +/// описано в `KeychainCredentials.resolveService`. +public struct AccountsConfig: Codable, Sendable, Equatable { + /// Дом первого аккаунта; у установки Claude Code по умолчанию `~/.claude`. + public var primaryHome: String + /// Дом второго — то же значение, что уходит в `CLAUDE_CONFIG_DIR`. + /// Несуществующий каталог значит «второго аккаунта нет»: переключатель + /// тогда не показывается вовсе, и настраивать его для этого не нужно. + public var secondaryHome: String + + public init( + primaryHome: String = "~/.claude", + secondaryHome: String = "~/.claude-b" + ) { + self.primaryHome = primaryHome + self.secondaryHome = secondaryHome + } + + public func home(_ account: UsageAccount) -> String { + switch account { + case .primary: primaryHome + case .secondary: secondaryHome + } + } +} + public enum MenuBarStyle: String, Codable, Sendable, CaseIterable { case percent case compact @@ -318,6 +369,10 @@ public struct Config: Codable, Sendable, Equatable { public var timeZone: String /// Секунды между опросами, не меньше `minimumRefreshInterval`. public var refreshInterval: TimeInterval + /// Аккаунт, выбранный кнопками в заголовке панели. + public var activeAccount: UsageAccount + /// Дома аккаунтов Claude Code. + public var accounts: AccountsConfig public var provider: ProviderPreference /// Часы, между которыми растёт план. Вне их он стоит: недельный лимит /// раскладывается по рабочему времени, а не по астрономическому. @@ -360,6 +415,8 @@ public struct Config: Codable, Sendable, Equatable { resetMinute: 0, timeZone: "", refreshInterval: 300, + activeAccount: .primary, + accounts: AccountsConfig(), provider: .auto, // Не `WorkHours.default` — та (11–24) остаётся запасным значением для // сломанных чисел в `WorkHours.validated()` и опорной точкой во всех @@ -383,6 +440,8 @@ public struct Config: Codable, Sendable, Equatable { resetMinute: Int, timeZone: String, refreshInterval: TimeInterval, + activeAccount: UsageAccount = .primary, + accounts: AccountsConfig = AccountsConfig(), provider: ProviderPreference, workHours: WorkHours = WorkHours.default, menuBarStyle: MenuBarStyle, @@ -400,6 +459,8 @@ public struct Config: Codable, Sendable, Equatable { self.resetMinute = resetMinute self.timeZone = timeZone self.refreshInterval = refreshInterval + self.activeAccount = activeAccount + self.accounts = accounts self.provider = provider self.workHours = workHours self.menuBarStyle = menuBarStyle @@ -424,6 +485,13 @@ public struct Config: Codable, Sendable, Equatable { resetMinute: try c.decodeIfPresent(Int.self, forKey: .resetMinute) ?? d.resetMinute, timeZone: try c.decodeIfPresent(String.self, forKey: .timeZone) ?? d.timeZone, refreshInterval: try c.decodeIfPresent(TimeInterval.self, forKey: .refreshInterval) ?? d.refreshInterval, + // Не `decodeIfPresent`: тот бросает на незнакомом значении, а + // бросок здесь стоит всего конфига — разбор файла обрывается, и + // человек молча теряет все свои настройки разом. Повод не + // выдуманный: сборка с Codex писала сюда `claude`, и такие файлы + // лежат на дисках. Незнакомый аккаунт — это первый. + activeAccount: (try? c.decode(UsageAccount.self, forKey: .activeAccount)) ?? d.activeAccount, + accounts: (try? c.decode(AccountsConfig.self, forKey: .accounts)) ?? d.accounts, provider: try c.decodeIfPresent(ProviderPreference.self, forKey: .provider) ?? d.provider, workHours: try c.decodeIfPresent(WorkHours.self, forKey: .workHours) ?? d.workHours, menuBarStyle: try c.decodeIfPresent(MenuBarStyle.self, forKey: .menuBarStyle) ?? d.menuBarStyle, diff --git a/Sources/ClaudeWeekCore/Keychain.swift b/Sources/ClaudeWeekCore/Keychain.swift index e1cf29f..38b227e 100644 --- a/Sources/ClaudeWeekCore/Keychain.swift +++ b/Sources/ClaudeWeekCore/Keychain.swift @@ -56,6 +56,20 @@ public protocol CredentialsSource: Sendable { func load() throws -> OAuthCredentials } +/// Кредов у аккаунта нет — в него ещё не вошли. +/// +/// Нужен именно отдельный тип, а не `nil`: там, где источник кредов +/// необязателен, `nil` означает «возьми Keychain по умолчанию», то есть запись +/// первого аккаунта. Для второго дома это тихо подставило бы чужой токен и +/// показало чужие цифры под его именем. +public struct SignedOutCredentials: CredentialsSource { + public init() {} + + public func load() throws -> OAuthCredentials { + throw UsageError.unauthorized + } +} + /// Читает запись Keychain, куда Claude Code кладёт свои OAuth-креды. /// /// Обновлением токена ClaudeWeek не занимается принципиально: refresh-цикл — @@ -231,3 +245,111 @@ public struct KeychainCredentials: CredentialsSource { return Date(timeIntervalSince1970: raw > 1e11 ? raw / 1000 : raw) } } + +/// Креды аккаунта, имя записи которого заранее не известно. +/// +/// У второго конфиг-дома Claude Code заводит свою запись Keychain, но её имя +/// собирает внутри себя и нигде не публикует. Выводить его формулой значило бы +/// закрепить догадку о чужой внутренней детали: она сменится с обновлением +/// CLI, и виджет молча покажет цифры не того аккаунта — худший исход из +/// возможных. Поэтому имя не выводится, а находится: среди записей Claude Code +/// берётся та, чей `organizationUuid` совпал с тем, что назвал +/// `claude auth status` для этого дома. Совпадение проверяет машина, а +/// неоднозначность честно оборачивается «нет доступа», а не случайным выбором. +public struct ResolvedKeychainCredentials: CredentialsSource { + /// Организация нужного аккаунта — из `claude auth status`. + private let organizationId: String + /// Запасной путь: креды файлом в самом доме аккаунта. + private let fileURL: URL + + public init(organizationId: String, fileURL: URL) { + self.organizationId = organizationId + self.fileURL = fileURL + } + + public func load() throws -> OAuthCredentials { + if let data = try? Data(contentsOf: fileURL), + let credentials = try? ResolvedKeychainCredentials.parse(data), + credentials.organizationUuid == organizationId { + return credentials + } + guard let service = ResolvedKeychainCredentials.service(for: organizationId) else { + throw UsageError.unauthorized + } + return try KeychainCredentials(service: service, fileURL: fileURL).load() + } + + /// Имя записи, принадлежащей этой организации. nil — записи нет, читать её + /// не дают или подходящих оказалось несколько. + public static func service(for organizationId: String) -> String? { + let matching = candidates().filter { service in + guard let data = KeychainCredentials.readViaSecurityTool(service: service), + let credentials = try? parse(data) + else { return false } + return credentials.organizationUuid == organizationId + } + guard matching.count == 1 else { + if matching.count > 1 { + Log.warn("записей Keychain с организацией \(organizationId) больше одной — не выбираю наугад") + } + return nil + } + return matching[0] + } + + /// Имена записей Claude Code в связке. Перечисление атрибутов пароля не + /// открывает, поэтому диалога доступа здесь не бывает. + static func candidates() -> [String] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/security") + process.arguments = ["dump-keychain"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + Log.debug("security dump-keychain не запустился: \(error.localizedDescription)") + return [] + } + let output = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return [] } + return services(in: String(decoding: output, as: UTF8.self)) + } + + /// Имена записей Claude Code в выводе `security dump-keychain`. + /// + /// Вынесено отдельно от запуска процесса, чтобы разбор проверялся тестом: + /// формат вывода — чужой и недокументированный, и ошибка в нём означала бы + /// не пустой список, а взятую наугад чужую запись. + public static func services(in output: String) -> [String] { + var names: [String] = [] + for line in output.split(separator: "\n") { + guard let open = line.range(of: "\"svce\"=\"")?.upperBound, + let close = line.lastIndex(of: "\""), + open < close + else { continue } + let name = String(line[open.. OAuthCredentials { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let token = KeychainCredentials.findToken(root), !token.isEmpty + else { throw UsageError.unauthorized } + let oauth = root["claudeAiOauth"] as? [String: Any] + return OAuthCredentials( + accessToken: token, + expiresAt: KeychainCredentials.date(from: oauth?["expiresAt"]), + subscriptionType: oauth?["subscriptionType"] as? String, + organizationUuid: (root["organizationUuid"] as? String) + ?? (oauth?["organizationUuid"] as? String) + ) + } +} diff --git a/Sources/ClaudeWeekTests/AccountTests.swift b/Sources/ClaudeWeekTests/AccountTests.swift new file mode 100644 index 0000000..a701ac2 --- /dev/null +++ b/Sources/ClaudeWeekTests/AccountTests.swift @@ -0,0 +1,166 @@ +import Foundation +import ClaudeWeekCore + +func runAccountTests(_ t: Harness) { + t.suite("пути аккаунта") { + var config = Config.default + config.accounts = AccountsConfig(primaryHome: "~/.claude", secondaryHome: "~/.claude-b") + + let primary = AccountLocation(account: .primary, config: config) + let secondary = AccountLocation(account: .secondary, config: config) + + t.equal(primary.home.path, NSHomeDirectory() + "/.claude", "тильда первого дома раскрыта") + t.equal(secondary.home.path, NSHomeDirectory() + "/.claude-b", "тильда второго дома раскрыта") + t.equal( + primary.projectsRoot.path, + NSHomeDirectory() + "/.claude/projects", + "транскрипты первого аккаунта" + ) + t.equal( + secondary.projectsRoot.path, + NSHomeDirectory() + "/.claude-b/projects", + "транскрипты второго аккаунта берутся из его дома, а не из общего" + ) + + // Первый аккаунт обязан сохранить прежние имена файлов: у работающей + // установки в них лежит накопленный счёт, и переезд обнулил бы его. + t.equal(primary.cacheURL.lastPathComponent, "cache.json", "кеш первого аккаунта не переименован") + t.equal(primary.alertsURL.lastPathComponent, "alerts.json", "журнал первого не переименован") + t.equal(primary.countingStateURL.lastPathComponent, "state.json", "счёт первого не переименован") + t.equal(primary.indexURL.lastPathComponent, "index.json", "индекс первого не переименован") + + t.equal(secondary.cacheURL.lastPathComponent, "secondary-cache.json", "кеш второго отдельный") + t.equal(secondary.alertsURL.lastPathComponent, "secondary-alerts.json", "журнал второго отдельный") + t.equal(secondary.countingStateURL.lastPathComponent, "secondary-state.json", "счёт второго отдельный") + t.equal(secondary.indexURL.lastPathComponent, "secondary-index.json", "индекс второго отдельный") + + t.check(primary.cacheURL != secondary.cacheURL, "снимки аккаунтов не в одном файле") + t.check( + primary.credentialsFileURL != secondary.credentialsFileURL, + "запасной файл кредов у каждого аккаунта свой" + ) + + // Пустой путь не должен превращаться в каталог с именем «~» или в + // корень: такой дом просто не существует, и переключатель не покажем. + let empty = AccountLocation(account: .secondary, home: AccountLocation.expand(" ")) + t.equal(empty.home.path, NSHomeDirectory(), "пустой путь сводится к домашнему каталогу") + } + + t.suite("состояние аккаунта") { + let signedIn = Data(""" + {"loggedIn": true, "authMethod": "claude.ai", "email": "kto@to.ru", + "orgId": "2a420a73-2872-4640-a99e-ed027d474338", "subscriptionType": "pro"} + """.utf8) + let status = AccountDirectory.parse(signedIn) + t.check(status.loggedIn, "вход распознан") + t.equal(status.email, "kto@to.ru", "адрес разобран") + t.equal(status.organizationId, "2a420a73-2872-4640-a99e-ed027d474338", "организация разобрана") + t.equal(status.title(fallback: "Второй"), "kto@to.ru", "подписью служит адрес") + + let signedOut = AccountDirectory.parse(Data(#"{"loggedIn": false, "authMethod": "none"}"#.utf8)) + t.check(!signedOut.loggedIn, "выход распознан") + t.equal(signedOut.organizationId, nil, "у вышедшего нет организации") + t.equal(signedOut.title(fallback: "Второй"), "Второй", "без адреса подпись порядковая") + + // Мусор вместо ответа не должен читаться как удачный вход: иначе + // второй аккаунт пошёл бы искать запись Keychain с организацией nil. + t.check(!AccountDirectory.parse(Data("не json".utf8)).loggedIn, "мусор — не вход") + t.check(!AccountDirectory.parse(Data()).loggedIn, "пустой ответ — не вход") + + // Тариф выручает, когда адреса в ответе не оказалось. + let noEmail = AccountDirectory.parse(Data(#"{"loggedIn": true, "subscriptionType": "max"}"#.utf8)) + t.equal(noEmail.title(fallback: "Второй"), "max", "без адреса подписью служит тариф") + } + + t.suite("поиск записи Keychain") { + // Форма вывода `security dump-keychain`: имя сервиса среди прочих + // атрибутов. Берём только записи Claude Code — в связке пользователя + // лежат сотни чужих. + let dump = """ + "svce"="Claude Code-credentials" + "acct"="greem4" + "svce"="Claude Safe Storage" + "svce"="GitHub-credentials" + "svce"="Claude Code-a1b2c3-credentials" + "svce"="Claude Code-credentials" + """ + let found = ResolvedKeychainCredentials.services(in: dump) + t.equal(found.count, 2, "нашлись обе записи Claude Code и ни одной лишней") + t.check(found.contains("Claude Code-credentials"), "запись первого аккаунта найдена") + t.check(found.contains("Claude Code-a1b2c3-credentials"), "запись второго аккаунта найдена") + t.check(!found.contains("Claude Safe Storage"), "запись без -credentials отброшена") + t.check(!found.contains("GitHub-credentials"), "чужая запись отброшена") + t.equal(ResolvedKeychainCredentials.services(in: "").count, 0, "пустой вывод — пустой список") + } + + t.suite("креды аккаунта") { + var config = Config.default + config.accounts = AccountsConfig(primaryHome: "~/.claude", secondaryHome: "~/.claude-b") + + // Второй аккаунт без входа обязан отдавать «нет авторизации», а не + // молча читать запись первого: иначе панель показала бы чужие цифры + // под его именем — худшее из возможных поведений. + let signedOut = ResolvingProvider.credentials( + for: .secondary, + config: config, + status: .signedOut + ) + var refused = false + do { + _ = try signedOut.load() + } catch { + refused = true + } + t.check(refused, "невошедший второй аккаунт не отдаёт чужой токен") + t.check(signedOut is SignedOutCredentials, "и делает это явным типом, а не nil") + + let signedIn = ResolvingProvider.credentials( + for: .secondary, + config: config, + status: AccountStatus(loggedIn: true, organizationId: "org-2") + ) + t.check(signedIn is ResolvedKeychainCredentials, "вошедший второй ищет свою запись по организации") + + let primary = ResolvingProvider.credentials( + for: .primary, + config: config, + status: .signedOut + ) + t.check(primary is KeychainCredentials, "первый аккаунт читает запись с известным именем") + } + + t.suite("аккаунт в конфиге") { + // Конфиг без новых ключей — обычный случай при обновлении: аккаунт + // должен оказаться первым, а дома — стандартными. + let old = Data(#"{"resetHour": 16, "provider": "auto"}"#.utf8) + let decoded = try JSONDecoder().decode(Config.self, from: old) + t.equal(decoded.activeAccount, .primary, "старый конфиг открывается на первом аккаунте") + t.equal(decoded.accounts.primaryHome, "~/.claude", "дом первого по умолчанию") + t.equal(decoded.accounts.secondaryHome, "~/.claude-b", "дом второго по умолчанию") + + // Сборка с Codex писала сюда `claude`, и такие файлы лежат на дисках. + // Незнакомое значение обязано стоить только самого поля: бросок здесь + // обрывает разбор всего файла, и человек молча теряет все настройки. + let fromCodexBuild = Data(#"{"activeAccount": "claude", "resetHour": 9, "weeklyBudget": 42}"#.utf8) + let survived = try JSONDecoder().decode(Config.self, from: fromCodexBuild) + t.equal(survived.activeAccount, .primary, "незнакомый аккаунт сводится к первому") + t.equal(survived.resetHour, 9, "соседние настройки пережили незнакомое значение") + t.close(survived.weeklyBudget, 42, "бюджет пережил незнакомое значение") + + let brokenHomes = Data(#"{"accounts": "не объект", "resetHour": 7}"#.utf8) + let repaired = try JSONDecoder().decode(Config.self, from: brokenHomes) + t.equal(repaired.accounts.primaryHome, "~/.claude", "битые дома сводятся к умолчанию") + t.equal(repaired.resetHour, 7, "и не уносят с собой остальной конфиг") + + // И обратно: выбранный аккаунт обязан пережить перезапуск. + var config = Config.default + config.activeAccount = .secondary + config.accounts = AccountsConfig(primaryHome: "~/.claude", secondaryHome: "~/.work") + let roundTrip = try JSONDecoder().decode(Config.self, from: JSONEncoder().encode(config)) + t.equal(roundTrip.activeAccount, .secondary, "выбранный аккаунт пережил запись и чтение") + t.equal(roundTrip.accounts.secondaryHome, "~/.work", "дом второго пережил запись и чтение") + + t.equal(config.accounts.home(.primary), "~/.claude", "дом первого берётся по имени аккаунта") + t.equal(config.accounts.home(.secondary), "~/.work", "дом второго берётся по имени аккаунта") + } +} diff --git a/Sources/ClaudeWeekTests/main.swift b/Sources/ClaudeWeekTests/main.swift index 3f549b8..c475a1c 100644 --- a/Sources/ClaudeWeekTests/main.swift +++ b/Sources/ClaudeWeekTests/main.swift @@ -4,6 +4,7 @@ import ClaudeWeekCore let t = Harness() runConfigTests(t) +runAccountTests(t) runWeekWindowTests(t) runPlanTests(t) runFormattingTests(t) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f8fe804..96c6767 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -79,6 +79,14 @@ SwiftUI` — значит, расчёт просочился в UI или нао `shapeIsEstimate` (разбивка). Это разные вещи, и смешивать их нельзя. - **«Что лежит в кеше»** → `CachedUsage`: проценты, границы окна, подобранный `weeklyBudget`, `officialWindowEnd` и последняя сессия. +- **«Откуда программа знает про второй аккаунт»** → `Accounts.swift`: + `AccountLocation` раскладывает конфиг-дом на пути (транскрипты, кеш, отсечка, + индекс), `AccountDirectory` спрашивает `claude auth status`, кто в этот дом + вошёл. Имя записи Keychain второго дома Claude Code нигде не публикует, + поэтому оно не выводится формулой, а находится по организации — + `ResolvedKeychainCredentials`. Аккаунту без входа достаётся + `SignedOutCredentials`, а не `nil`: `nil` в `ResolvingProvider` означает + «Keychain по умолчанию», то есть запись первого аккаунта. ### Инварианты ядра @@ -413,7 +421,7 @@ swift build # оба таргета # объявления: ключи kSecUseAuthenticationUI в Keychain.swift оставлены # намеренно, замены им нет, и группа понижена обратно до предупреждения. swift build -Xswiftc -warnings-as-errors -Xswiftc -Wwarning -Xswiftc DeprecatedDeclaration -swift run ClaudeWeekTests # 477 проверок, без сети и без UI +swift run ClaudeWeekTests # 534 проверки, без сети и без UI swift run ClaudeWeekApp # запустить из исходников (появится вторая иконка!) ./scripts/signing-cert.sh # один раз: постоянный сертификат подписи ./scripts/make-app.sh # собрать dist/ClaudeWeek.app @@ -593,8 +601,11 @@ Intel — собирает у себя, `install.sh` соберёт нативн | `~/.config/claude-week/alerts.json` | о каких порогах уведомления уже говорили: окна лимитов, последние объявленные проценты, момент последнего баннера. Удаление безопасно — вернётся одно повторное уведомление | | `~/.config/claude-week/state.json` | с какого момента считается локальный расход и на каком аккаунте: `countFrom` и метка организации. Отдельно от кеша намеренно — тот перезаписывается каждым обновлением, а отсечка обязана его пережить. Удаление вернёт в счёт расход прежнего аккаунта | | `~/.config/claude-week/index.json` | индекс прочитанных транскриптов (инкрементальное чтение); схема 2 — записи хранят семейство модели и токены, из них считается разбивка. Индекс прошлой схемы не переносится, а отстраивается заново: версия проверяется до разбора записей | +| `~/.config/claude-week/secondary-*.json` | то же самое для второго аккаунта: `secondary-cache.json`, `secondary-alerts.json`, `secondary-state.json`, `secondary-index.json`. Порознь намеренно — общий кеш позволил бы обновлению одного аккаунта стереть снимок другого, а общая отсечка отдала бы счёт не тому лимиту. У первого аккаунта имена прежние: в них лежит накопленный счёт, и переезд обнулил бы его | | `~/.claude/projects/**/*.jsonl` | транскрипты Claude Code — вход локального источника | +| `~/.claude-b/` | конфиг-дом второго аккаунта, если он заведён: свои транскрипты в `projects/`, своя запись Keychain. Путь задаётся ключом `accounts.secondaryHome`; каталога нет — переключателя в панели тоже нет | | `~/Library/Logs/ClaudeWeek.log` | лог запущенного через LaunchAgent приложения | | `~/Library/LaunchAgents/com.greem4.claudeweek.plist` | автозапуск | | `~/Applications/ClaudeWeek.app` | установленная копия | -| Keychain `Claude Code-credentials` | токен Claude Code (только читаем) | +| Keychain `Claude Code-credentials` | токен Claude Code первого аккаунта (только читаем) | +| Keychain, запись второго аккаунта | имя Claude Code собирает внутри себя и не публикует. Не угадываем: среди записей `*-credentials` берём ту, чей `organizationUuid` совпал с `orgId` из `claude auth status` для этого дома. Совпадений не одно — читаем «нет доступа», а не выбираем наугад | diff --git a/docs/USAGE.md b/docs/USAGE.md index f39ca2d..0a105da 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -7,6 +7,7 @@ ## Оглавление - [Авторизация и доступ](#авторизация-и-доступ) +- [Два аккаунта](#два-аккаунта) - [Калибровка](#калибровка) - [Панель](#панель) - [Как считается план](#как-считается-план) @@ -130,6 +131,78 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 секреты `SIGNING_CERT_P12` (файл `.p12` в base64) и `SIGNING_CERT_PASSWORD`; без них релиз собирается ad-hoc, как раньше. +## Два аккаунта + +Claude Code различает аккаунты **конфиг-домом** — каталогом, где лежат его +настройки, транскрипты и учётные данные. Обычная установка живёт в `~/.claude`. +Второй аккаунт — это второй такой каталог, и путь к нему передаётся переменной +`CLAUDE_CONFIG_DIR`. + +Так его заводят: + +```bash +mkdir -p ~/.claude-b +CLAUDE_CONFIG_DIR=~/.claude-b claude # внутри: /login вторым аккаунтом +``` + +Проверить, что дома действительно два и вход в один не выбил другой: + +```bash +claude auth status # первый аккаунт +CLAUDE_CONFIG_DIR=~/.claude-b claude auth status # второй +``` + +Разные адреса в ответах — всё получилось. Записей в Keychain при этом тоже +станет две: у каждого дома своя, и `/login` во втором не трогает токен первого. + +### Что делает виджет + +Каталог `~/.claude-b` появился — в заголовке панели встают **два знака +Claude**. Выбранный аккаунт горит фирменным оранжевым, второй гаснет до +приглушённого серого. Знаки одинаковые, потому что сервис один; кто из них +кто, говорит подсказка при наведении — там стоит адрес аккаунта, который +назвал `claude auth status`, а не порядковый номер. Выбор сохраняется в +конфиге и переживает перезапуск. + +У каждого аккаунта **своё всё**: свой недельный лимит, свой снимок, свой +подобранный бюджет, своя отсечка счёта и свой журнал уведомлений. Файлы +второго аккаунта лежат рядом с файлами первого под именами с приставкой +`secondary-`. Общими их сделать было нельзя: последнее обновление одного +аккаунта стирало бы снимок другого. + +Пока во второй дом не вошли, панель на нём не рисует серые заглушки — это +читалось бы как вечная загрузка. Вместо них стоит строка о том, что входа нет, +и путь, который нужно передать в `CLAUDE_CONFIG_DIR`. + +Каталога второго дома нет вовсе — нет и переключателя: программа работает +ровно так же, как с одним аккаунтом, и настраивать для этого ничего не нужно. + +### Откуда берётся токен второго аккаунта + +Запись Keychain второго дома Claude Code называет по-своему и это имя нигде не +публикует. Выводить его формулой значило бы закрепить догадку о чужой +внутренней детали: она сменится с обновлением CLI, и виджет молча покажет +цифры не того аккаунта. Поэтому имя не выводится, а **находится**: среди +записей Claude Code берётся та, чей `organizationUuid` совпал с `orgId`, +который назвал `claude auth status` для этого дома. Совпадений оказалось не +одно — виджет честно скажет «нет доступа», а не выберет наугад. + +По той же причине аккаунт, в который не вошли, не получает «креды по +умолчанию»: иначе он показал бы цифры первого аккаунта под своим именем. + +### Путь второго дома + +Задаётся ключом `accounts` в `config.json`, по умолчанию `~/.claude-b`: + +```json +{ + "accounts": { + "primaryHome": "~/.claude", + "secondaryHome": "~/.claude-b" + } +} +``` + ## Калибровка Руками ничего подбирать не нужно. На каждом успешном официальном ответе @@ -500,7 +573,14 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 сейчас» — она делает один настоящий запрос и печатает результат: процент или причину отказа. -Сверху — **«Сейчас в ключе»**: метка аккаунта из записи Keychain, начало UUID +Сверху — раздел **«Аккаунты»**: оба конфиг-дома, путь каждого и кто в него +вошёл. Три положения различаются намеренно: «дом не заведён» — каталога нет, +второй аккаунт не заводили; «не вошли» — каталог есть, но `/login` в нём не +делали; адрес — вошли, и видно, кто именно. Советы человеку в первых двух +случаях разные, поэтому одним «нет данных» они не склеиваются. Подробности — +в [Два аккаунта](#два-аккаунта). + +Ниже — **«Сейчас в ключе»**: метка аккаунта из записи Keychain, начало UUID организации и тариф (`7f3a1b2c·max`). Токен обновляется раз в час, а метка держится — по ней и видно, тот же это аккаунт, что вчера, или вошли другим. В режиме «только локальная оценка» строка пустая: там Keychain не читается @@ -748,6 +828,9 @@ GitHub — запись в лог, и ничего поверх работы. пробуждение и смену таймзоны, перечитывание конфига после правки. - **Автозапуск** — LaunchAgent, приложение живёт только в строке меню (`LSUIElement`), без иконки в Dock. +- **Выбор аккаунта** — флаг `--account=primary|secondary` выбирает, чей лимит + показать, в том числе для `--json`. Без флага берётся тот аккаунт, что + выбран в панели. - **Доступность** — разметка для VoiceOver (вживую ещё не прослушана), уважение к «Уменьшению движения», числа рядом с каждой полосой: цвет нигде не остаётся единственным носителем смысла. @@ -757,8 +840,8 @@ GitHub — запись в лог, и ничего поверх работы. Вошли в Claude Code другим аккаунтом — рабочим вместо домашнего, — и виджет показывает две правды сразу. Недельный процент приходит от сервера и сменяется сам: сервер узнаёт аккаунт по токену. А вот разбивка по суткам считается по -транскриптам в `~/.claude/projects`, и они пишутся в одни и те же файлы, кто бы -ни был залогинен. Различить их по содержимому нечем — маркера аккаунта в +транскриптам в `projects/` того же конфиг-дома, и они пишутся в одни и те же +файлы, кто бы ни был залогинен в этот дом. Различить их по содержимому нечем — маркера аккаунта в записях нет. Без отсечки расход прежнего аккаунта считался бы в новый лимит до конца недели. diff --git a/scripts/make-app.sh b/scripts/make-app.sh index 1fd3125..b0d9d9e 100755 --- a/scripts/make-app.sh +++ b/scripts/make-app.sh @@ -24,12 +24,14 @@ cd "$ROOT" # `$(...)` увёл бы туда же вывод самой сборки, а `exit 1` при ненайденном # бинаре погасил бы только подоболочку, и скрипт поехал бы дальше. SLICE="" +BIN_DIR="" build_slice() { local arch="$1" echo "==> swift build -c $CONFIG --arch $arch" swift build -c "$CONFIG" --arch "$arch" --product ClaudeWeekApp - SLICE="$(swift build -c "$CONFIG" --arch "$arch" --product ClaudeWeekApp \ - --show-bin-path)/ClaudeWeekApp" + BIN_DIR="$(swift build -c "$CONFIG" --arch "$arch" --product ClaudeWeekApp \ + --show-bin-path)" + SLICE="$BIN_DIR/ClaudeWeekApp" [ -x "$SLICE" ] || { echo "не нашёл бинарь $arch: $SLICE" >&2; exit 1; } } @@ -45,7 +47,8 @@ elif [ -n "$ARCH" ]; then else echo "==> swift build -c $CONFIG" swift build -c "$CONFIG" --product ClaudeWeekApp - SLICE="$(swift build -c "$CONFIG" --product ClaudeWeekApp --show-bin-path)/ClaudeWeekApp" + BIN_DIR="$(swift build -c "$CONFIG" --product ClaudeWeekApp --show-bin-path)" + SLICE="$BIN_DIR/ClaudeWeekApp" [ -x "$SLICE" ] || { echo "не нашёл бинарь: $SLICE" >&2; exit 1; } SLICES+=("$SLICE") fi @@ -64,6 +67,21 @@ fi cp "$ROOT/Resources/Info.plist" "$APP/Contents/Info.plist" printf 'APPL????' > "$APP/Contents/PkgInfo" +# Ресурсный бандл SwiftPM со знаком Claude для переключателя аккаунтов. Без +# него `Bundle.module` в приложении обрывается fatalError'ом ещё до отрисовки +# панели, поэтому его отсутствие — ошибка сборки, а не пропущенное украшение. +# У универсального бандла срезы кладут одинаковые ресурсы, хватает любого из +# проходов. +RES_BUNDLE="$BIN_DIR/ClaudeWeek_ClaudeWeekApp.bundle" +if [ -d "$RES_BUNDLE" ]; then + rm -rf "$APP/Contents/Resources/$(basename "$RES_BUNDLE")" + cp -R "$RES_BUNDLE" "$APP/Contents/Resources/" + echo "==> ресурсы: $(basename "$RES_BUNDLE")" +else + echo "не нашёл ресурсный бандл: $RES_BUNDLE" >&2 + exit 1 +fi + # Версия у программы одна — та, что в Version.swift; в plist она попадает # отсюда, а не переписывается руками во втором месте. VERSION="$(sed -n 's/.*static let version = "\(.*\)".*/\1/p' \ From 4beb9638135671279e80d1cef7c742416974b571 Mon Sep 17 00:00:00 2001 From: Greem4 Date: Mon, 31 Aug 2026 18:09:57 +0300 Subject: [PATCH 2/5] =?UTF-8?q?fix(app):=20=D0=BD=D0=B5=20=D1=81=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D1=8E=20=D0=BD=D0=B5=D0=B2=D0=BE=D1=88=D0=B5?= =?UTF-8?q?=D0=B4=D1=88=D0=B8=D0=BC=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D1=82=20=D1=81=D0=BE=20=D1=81=D1=82=D0=B0=D0=BD=D0=B4=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BD=D1=8B=D0=BC=20=D0=B4=D0=BE=D0=BC=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вкладка «Доступ» показывала «не вошли» у аккаунта, в котором человек работает прямо сейчас. Состояние дома выясняется вызовом `claude auth status`, и вызывали мы его всегда с `CLAUDE_CONFIG_DIR`. А эта переменная, выставленная даже в тот же самый путь `~/.claude` — тот, что Claude Code берёт по умолчанию, — заставляет его отвечать `loggedIn: false`, тогда как без переменной тот же дом отвечает `true` (проверено на 2.1.251). Ячейку учётных данных выбирает не путь сам по себе, а факт установки переменной. Теперь переменная ставится только для нестандартного дома, а унаследованная из окружения снимается: приложение могли запустить из оболочки, где она уже выставлена, — например из сеанса под вторым аккаунтом. Заодно перестал судить по коду возврата: у невошедшего аккаунта это единица, но JSON в ответе осмысленный. Проверок стало 538. Записал грабли в docs/ARCHITECTURE.md и docs/USAGE.md и поправил счётчик в README. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018rK6AqLE4HgSaXzqGJCRt2 --- CHANGELOG.md | 7 +++++ README.md | 2 +- Sources/ClaudeWeekCore/Accounts.swift | 33 ++++++++++++++++++++-- Sources/ClaudeWeekTests/AccountTests.swift | 16 +++++++++++ docs/ARCHITECTURE.md | 13 ++++++++- docs/USAGE.md | 6 +++- 6 files changed, 72 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac35e1..bc23133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,13 @@ ### Исправлено +- **Вкладка «Доступ» показывала «не вошли» у аккаунта, в котором вы работаете.** + Состояние дома выяснялось вызовом `claude auth status` с + `CLAUDE_CONFIG_DIR`, а эта переменная, выставленная даже в тот же самый путь + `~/.claude`, заставляет Claude Code отвечать `loggedIn: false`: ячейку + учётных данных выбирает не путь, а факт установки переменной. Теперь она + ставится только для нестандартного дома, а унаследованная из окружения + снимается — приложение могли запустить из оболочки, где она уже выставлена. - **Незнакомое значение `activeAccount` больше не стоит всего конфига.** Разбор поля бросал исключение, а оно обрывало чтение файла целиком — человек молча терял все свои настройки разом. Повод не выдуманный: сборка с Codex diff --git a/README.md b/README.md index 8a80936..42a2511 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ Keychain с той, на которой ведётся счёт, и при ра ```bash swift build # обе цели -swift run ClaudeWeekTests # 534 проверки: без сети, без UI, свой раннер +swift run ClaudeWeekTests # 538 проверок: без сети, без UI, свой раннер ./scripts/make-app.sh # dist/ClaudeWeek.app — бандл, ничего не устанавливая ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg ``` diff --git a/Sources/ClaudeWeekCore/Accounts.swift b/Sources/ClaudeWeekCore/Accounts.swift index 0cf2d54..34e7f38 100644 --- a/Sources/ClaudeWeekCore/Accounts.swift +++ b/Sources/ClaudeWeekCore/Accounts.swift @@ -59,6 +59,18 @@ public struct AccountLocation: Sendable, Equatable { public var countingStateURL: URL { stateFile("state.json") } public var indexURL: URL { stateFile("index.json") } + /// Дом, который Claude Code берёт сам, когда `CLAUDE_CONFIG_DIR` не задан. + /// Значим не только как путь: спрашивать про него надо именно без + /// переменной — см. `AccountDirectory.read(home:)`. + public static var defaultHome: URL { + URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".claude", isDirectory: true) + } + + /// Дом стандартный — тот, что достаётся установке Claude Code из коробки. + public var isDefaultHome: Bool { + home.standardizedFileURL.path == AccountLocation.defaultHome.standardizedFileURL.path + } + /// `~` в пути конфига раскрываем сами: путь пишет человек, а `URL` тильду /// не понимает и молча заводит каталог с именем «~». public static func expand(_ path: String) -> URL { @@ -150,7 +162,22 @@ public actor AccountDirectory { process.executableURL = executable process.arguments = ["auth", "status"] var environment = ProcessInfo.processInfo.environment - environment["CLAUDE_CONFIG_DIR"] = home.path + // Переменную ставим только для нестандартного дома, и это не + // придирка. `CLAUDE_CONFIG_DIR=~/.claude` — тот же самый путь, что + // берётся по умолчанию, — даёт `loggedIn: false`, тогда как без + // переменной тот же дом отвечает `true` (проверено на 2.1.251). + // Значит ячейку учётных данных выбирает не путь сам по себе, а факт + // установки переменной. Ставя её всегда, мы объявляли бы невошедшим + // аккаунт, в котором человек сидит прямо сейчас. + // + // Унаследованное значение при этом обязательно убрать: приложение + // могли запустить из оболочки, где оно уже выставлено, — например из + // сеанса под вторым аккаунтом. + if AccountLocation(account: .primary, home: home).isDefaultHome { + environment.removeValue(forKey: "CLAUDE_CONFIG_DIR") + } else { + environment["CLAUDE_CONFIG_DIR"] = home.path + } process.environment = environment let pipe = Pipe() @@ -174,7 +201,9 @@ public actor AccountDirectory { process.waitUntilExit() timeout.cancel() - guard process.terminationStatus == 0 else { return .signedOut } + // Код возврата не проверяем: у невошедшего аккаунта он единица, но + // ответ при этом осмысленный и разбирается тем же путём. Судим по + // содержимому, а не по коду. return parse(output) } diff --git a/Sources/ClaudeWeekTests/AccountTests.swift b/Sources/ClaudeWeekTests/AccountTests.swift index a701ac2..e654f8f 100644 --- a/Sources/ClaudeWeekTests/AccountTests.swift +++ b/Sources/ClaudeWeekTests/AccountTests.swift @@ -40,6 +40,22 @@ func runAccountTests(_ t: Harness) { "запасной файл кредов у каждого аккаунта свой" ) + // Стандартный дом надо уметь отличать: про него `claude auth status` + // спрашивают БЕЗ CLAUDE_CONFIG_DIR. С переменной, выставленной в тот + // же самый путь, он отвечает «не вошли» — ячейку учётных данных + // выбирает не путь, а факт установки переменной (проверено на 2.1.251). + t.check(primary.isDefaultHome, "~/.claude опознан как стандартный дом") + t.check(!secondary.isDefaultHome, "~/.claude-b стандартным не считается") + t.equal( + AccountLocation.defaultHome.path, + NSHomeDirectory() + "/.claude", + "стандартный дом — тот, что Claude Code берёт сам" + ) + // Путь с хвостовым слешем — тот же дом: сравнение идёт по + // приведённому виду, иначе запись в конфиге решала бы, вошли мы или нет. + let slashed = AccountLocation(account: .primary, home: AccountLocation.expand("~/.claude/")) + t.check(slashed.isDefaultHome, "хвостовой слеш не делает дом нестандартным") + // Пустой путь не должен превращаться в каталог с именем «~» или в // корень: такой дом просто не существует, и переключатель не покажем. let empty = AccountLocation(account: .secondary, home: AccountLocation.expand(" ")) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 96c6767..66e4610 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -421,7 +421,7 @@ swift build # оба таргета # объявления: ключи kSecUseAuthenticationUI в Keychain.swift оставлены # намеренно, замены им нет, и группа понижена обратно до предупреждения. swift build -Xswiftc -warnings-as-errors -Xswiftc -Wwarning -Xswiftc DeprecatedDeclaration -swift run ClaudeWeekTests # 534 проверки, без сети и без UI +swift run ClaudeWeekTests # 538 проверок, без сети и без UI swift run ClaudeWeekApp # запустить из исходников (появится вторая иконка!) ./scripts/signing-cert.sh # один раз: постоянный сертификат подписи ./scripts/make-app.sh # собрать dist/ClaudeWeek.app @@ -512,6 +512,17 @@ Intel — собирает у себя, `install.sh` соберёт нативн ### Грабли +- **`CLAUDE_CONFIG_DIR`, выставленный в тот же самый путь, «разлогинивает» + аккаунт.** `claude auth status` без переменной отвечает `loggedIn: true`, а + с `CLAUDE_CONFIG_DIR=~/.claude` — то есть с тем же путём, который берётся по + умолчанию, — уже `loggedIn: false` (2.1.251). Ячейку учётных данных выбирает + не путь сам по себе, а факт установки переменной. Поэтому + `AccountDirectory.read(home:)` ставит её **только для нестандартного дома**, + а унаследованную из окружения — снимает: приложение могли запустить из + оболочки, где она уже выставлена. Ставя её всегда, программа объявляла бы + невошедшим тот аккаунт, в котором человек работает прямо сейчас. +- **Код возврата `claude auth status` — не признак поломки.** У невошедшего + аккаунта это единица, но JSON в ответе осмысленный. Судим по содержимому. - **Keychain спрашивает доступ после каждой пересборки** — разрешение привязано к designated requirement, а у ad-hoc подписи это хеш бинаря. Лечится постоянным сертификатом: `./scripts/signing-cert.sh` один раз, дальше diff --git a/docs/USAGE.md b/docs/USAGE.md index 0a105da..d818ba9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -152,7 +152,11 @@ claude auth status # первый аккаун CLAUDE_CONFIG_DIR=~/.claude-b claude auth status # второй ``` -Разные адреса в ответах — всё получилось. Записей в Keychain при этом тоже +Разные адреса в ответах — всё получилось. Обратите внимание: про первый дом +спрашивать нужно **без** `CLAUDE_CONFIG_DIR`. С этой переменной, выставленной +даже в тот же самый путь `~/.claude`, Claude Code отвечает «не вошли» — ячейку +учётных данных выбирает не путь, а сам факт установки переменной. Виджет это +учитывает и переменную для стандартного дома не ставит. Записей в Keychain при этом тоже станет две: у каждого дома своя, и `/login` во втором не трогает токен первого. ### Что делает виджет From 3c1393de46bb358abe9c68d29dadba3d52941c7e Mon Sep 17 00:00:00 2001 From: Greem4 Date: Mon, 31 Aug 2026 18:10:08 +0300 Subject: [PATCH 3/5] =?UTF-8?q?style(app):=20=D1=83=D0=B1=D0=B8=D1=80?= =?UTF-8?q?=D0=B0=D1=8E=20=D0=B4=D0=BB=D0=B8=D0=BD=D0=BD=D1=83=D1=8E=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D1=83=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0=20=C2=AB?= =?UTF-8?q?=D0=90=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D1=8B=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Абзац объяснял, что второй аккаунт — это отдельный конфиг-дом, и как его завести. В настройках это была документация, а не настройка: тот же текст уже лежит в docs/USAGE.md, куда за ним и приходят. Осталась одна строка — про то, что у каждого аккаунта свой лимит, снимок и отсечка счёта. Она объясняет, что делает переключатель рядом, и потому остаётся на месте. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018rK6AqLE4HgSaXzqGJCRt2 --- Sources/ClaudeWeekApp/SettingsView.swift | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/Sources/ClaudeWeekApp/SettingsView.swift b/Sources/ClaudeWeekApp/SettingsView.swift index 3813d5c..ceb4e35 100644 --- a/Sources/ClaudeWeekApp/SettingsView.swift +++ b/Sources/ClaudeWeekApp/SettingsView.swift @@ -923,24 +923,6 @@ private struct AccessSettings: View { } } - Text(s.pick( - """ - Второй аккаунт — это отдельный конфиг-дом Claude Code. \ - Запустите Claude Code с CLAUDE_CONFIG_DIR, указывающим на путь выше, \ - и войдите там своим вторым аккаунтом: переключатель в заголовке \ - панели появится сам, как только каталог возникнет. Путь меняется \ - ключом accounts в config.json. - """, - """ - The second account is a separate Claude Code config home. \ - Start Claude Code with CLAUDE_CONFIG_DIR pointing at the path above \ - and sign in there with your second account: the switch in the panel \ - header appears on its own once the directory exists. The path is set \ - by the accounts key in config.json. - """ - )) - .settingsHint() - Text(s.pick( "У каждого аккаунта свой лимит, свой снимок и своя отсечка счёта — переключатель меняет их все разом.", "Each account has its own limit, its own snapshot and its own counting cut-off — the switch changes all of them at once." From fea081566939c8d0c7d2b1132e09f00c0e90ec7d Mon Sep 17 00:00:00 2001 From: Greem4 Date: Mon, 31 Aug 2026 18:12:07 +0300 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=8E=20=D0=B2=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=83=D0=BD=D0=BA=D1=82=20=D0=BF=D1=80=D0=BE=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пробел №10 «Только один аккаунт за раз» стал неправдой: аккаунтов в панели два, состояние у каждого своё, и обратное переключение ничего не теряет. По формату этого файла закрытый пункт уходит из списка пробелов в журнал — туда же ушли и грабли с CLAUDE_CONFIG_DIR. Нумерация подтянулась: «Сборки не нотаризованы» стали десятым пунктом, поправлено оглавление. Честно записал, что осталось за бортом: аккаунтов ровно два, произвольного числа нет — под него пришлось бы заводить список в конфиге, а спроса на третий пока не видно. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018rK6AqLE4HgSaXzqGJCRt2 --- docs/ROADMAP.md | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d1cbcd8..5f05466 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,10 +13,10 @@ - [Соседи по полке](#соседи-по-полке) — что умеют похожие программы - [Заметно сразу](#заметно-сразу) — 1–2 - [Заметно в углах](#заметно-в-углах) — 3–8 -- [Ограничения, с которыми живём](#ограничения-с-которыми-живём) — 9–11 +- [Ограничения, с которыми живём](#ограничения-с-которыми-живём) — 9–10 - [Что сознательно не делаем](#что-сознательно-не-делаем) - [Что закрыл аудит](#что-закрыл-аудит-2026-08-05) — журналы 2026-08-05, 06 и 07 -- [Что сделано после аудита](#что-сделано-после-аудита) — журналы 2026-08-08, 10, 15, 16 и 17 +- [Что сделано после аудита](#что-сделано-после-аудита) — журналы 2026-08-08, 10, 15, 16, 17 и 31 --- @@ -186,24 +186,7 @@ XCTest без Xcode недоступен, поэтому проверки нап *Страховка:* `swift scripts/probe-usage.swift` печатает живой ответ целиком; запасной путь через `limits[]` уже есть. -### 10. Только один аккаунт за раз - -Токен один и всегда из Claude Code: поля «свой токен» в приложении нет — -`/api/oauth/usage` принимает только токен сеанса, остальные отвергает с `401` -(разбор в [API.md](API.md#токен)). Виджет показывает тот аккаунт, под которым -сейчас работает Claude Code, и показать два рядом — рабочий и личный — нельзя. - -Смена аккаунта при этом уже не врёт: программа замечает её по метке -организации из Keychain и начинает счёт заново, а вручную то же делает кнопка -«Начать счёт заново» ([USAGE.md](USAGE.md#смена-аккаунта)). Чего нет — истории -по каждому аккаунту: после переключения прежние цифры не сохраняются, а -отбрасываются. - -*Что делать:* хранить состояние по метке аккаунта, а не одним файлом, и -переключатель в панели. Работы на день, но польза сомнительна, пока никто не -попросил. - -### 11. Сборки не нотаризованы +### 10. Сборки не нотаризованы Лицензия (MIT) и CI появились, релиз собирается по тегу и выкладывается `.dmg` под Apple Silicon; Intel-образа в релизах нет вовсе — там сборка из @@ -336,3 +319,10 @@ requirement не менялся и приложение держалось в с | Было | Стало | |---|---| | на двух мониторах, поставленных один над другим, панель вставала не под строкой меню, а у нижнего края соседнего экрана — оторванная от значка на пол-экрана. Строка меню в полноэкранном режиме прячется, и её окно вместе со значком уезжает вверх на свою высоту: у вплотную стоящих мониторов этих 30 точек хватает, чтобы значок оказался на территории соседа, а `anchor.screen` честно ответил «соседний». Дальше панель клэмпилась по чужим границам — замер совпал с расчётом до точки: значок `y1080…1110` → панель `y1088…1416`, где `1088 = 1080 + 8` | экран выбирает `DropdownPanel.screen(for:)` по признаку, который сдвиг строки меню переживает: она всегда прижата к верхней кромке своего экрана, значит значок принадлежит тому из накрывающих его по горизонтали мониторов, чья верхняя кромка ближе. Верхняя кромка панели вдобавок ограничена `screen.maxY` — спрятанная строка меню уводила бы за край и её. Разбор и замеры — [SPACES.md](SPACES.md), §7; проба `probe-panel.swift --displays` | + +### 2026-08-31 + +| Было | Стало | +|---|---| +| виджет показывал ровно один аккаунт — тот, под которым сейчас работает Claude Code, — и поставить рядом рабочий с личным было нельзя. Прежние цифры при переключении не сохранялись, а отбрасывались (бывший пункт 10) | два аккаунта в панели. Claude Code различает их конфиг-домом, и второй дом (`~/.claude-b` плюс `CLAUDE_CONFIG_DIR`) держит свой токен, не выбивая первый. Появился каталог — в заголовке встают два знака Claude; выбранный горит оранжевым, второй гаснет до серого, а кто из них кто, говорит подсказка с живым адресом из `claude auth status`. Состояние хранится по аккаунту, а не одним файлом: у второго свои `secondary-cache.json`, `secondary-alerts.json`, `secondary-state.json` и `secondary-index.json`, поэтому обратное переключение мгновенно и ничего не теряет. Имя записи Keychain второго дома Claude Code не публикует — оно не выводится формулой, а находится по организации, которую назвал `claude auth status`. Каталога нет — нет и переключателя. Осталось за бортом: аккаунтов ровно два, произвольного числа нет — под него пришлось бы заводить список в конфиге, а спроса на третий пока не видно | +| `claude auth status` спрашивали всегда с `CLAUDE_CONFIG_DIR`, и вкладка «Доступ» показывала «не вошли» у аккаунта, в котором человек работает прямо сейчас | переменная ставится только для нестандартного дома. Выставленная даже в тот же самый путь `~/.claude`, она заставляет Claude Code отвечать `loggedIn: false` — ячейку учётных данных выбирает не путь, а факт установки переменной (2.1.251). Унаследованная из окружения снимается: приложение могли запустить из оболочки под вторым аккаунтом. Грабли записаны в [ARCHITECTURE.md](ARCHITECTURE.md#грабли) | From 8c76f8ac69f81211d176fda982e9768e087386f8 Mon Sep 17 00:00:00 2001 From: Greem4 Date: Mon, 31 Aug 2026 19:34:41 +0300 Subject: [PATCH 5/5] =?UTF-8?q?fix(accounts):=20=D0=B1=D0=B5=D1=80=D1=83?= =?UTF-8?q?=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=20=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20=D0=B8=D0=B7=20=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B4=D0=BE=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code 2.1.251 убрал `organizationUuid` из записи Keychain, и поиск записи перебором со сверкой этого поля перестал находить хоть что-нибудь: второй аккаунт показывал «в этот аккаунт ещё не вошли», хотя вход был. Имя записи теперь выводится из пути конфиг-дома — так же, как его собирает сам Claude Code, — и формулу сторожит тест с живым вектором. Промах формулы даёт «нет доступа», а не чужой токен: имя зависит от дома, а не от аккаунта. Принадлежность токена приписывается снаружи, от `claude auth status`, — иначе метка аккаунта не собиралась бы и отсечка счёта не замечала входа другим аккаунтом. Форма метки прежняя, накопленный счёт на месте. --- CHANGELOG.md | 17 ++ README.md | 2 +- Sources/ClaudeWeekCore/Accounts.swift | 32 ++-- Sources/ClaudeWeekCore/Keychain.swift | 181 +++++++++------------ Sources/ClaudeWeekTests/AccountTests.swift | 78 ++++++--- docs/API.md | 23 ++- docs/ARCHITECTURE.md | 12 +- docs/PLAN.md | 2 +- docs/ROADMAP.md | 2 +- docs/USAGE.md | 22 ++- 10 files changed, 216 insertions(+), 155 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc23133..27ea492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,23 @@ учётных данных выбирает не путь, а факт установки переменной. Теперь она ставится только для нестандартного дома, а унаследованная из окружения снимается — приложение могли запустить из оболочки, где она уже выставлена. +- **Второй аккаунт показывал «в этот аккаунт ещё не вошли», хотя вход был.** + Токен второго дома искали перебором записей 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 diff --git a/README.md b/README.md index 42a2511..0d2f2ed 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ Keychain с той, на которой ведётся счёт, и при ра ```bash swift build # обе цели -swift run ClaudeWeekTests # 538 проверок: без сети, без UI, свой раннер +swift run ClaudeWeekTests # 541 проверка: без сети, без UI, свой раннер ./scripts/make-app.sh # dist/ClaudeWeek.app — бандл, ничего не устанавливая ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg ``` diff --git a/Sources/ClaudeWeekCore/Accounts.swift b/Sources/ClaudeWeekCore/Accounts.swift index 34e7f38..6510ab3 100644 --- a/Sources/ClaudeWeekCore/Accounts.swift +++ b/Sources/ClaudeWeekCore/Accounts.swift @@ -253,24 +253,30 @@ public extension ResolvingProvider { /// Откуда берётся токен аккаунта. Отдельно от фабрики, потому что тем же /// ответом подписывается отсечка счёта: спрашивать «чей это аккаунт» надо /// у того же источника, из которого потом читают токен. + /// + /// Оба аккаунта ходят одной дорогой — за записью, названной по их дому. + /// Стандартному дому та же формула отдаёт имя, которое Claude Code завёл + /// при установке, так что первому аккаунту отдельная ветка не нужна; зато + /// первый с переставленным домом перестал читать чужую запись. static func credentials( for account: UsageAccount, config: Config, status: AccountStatus ) -> CredentialsSource { let location = AccountLocation(account: account, config: config) - switch account { - case .primary: - // Первый аккаунт живёт в записи с известным именем — той самой, - // которую Claude Code завёл при установке. - return KeychainCredentials(fileURL: location.credentialsFileURL) - case .secondary: - return status.organizationId.map { - ResolvedKeychainCredentials( - organizationId: $0, - fileURL: location.credentialsFileURL - ) as CredentialsSource - } ?? SignedOutCredentials() - } + // Второй аккаунт без входа не читает ничего. Его дом можно вписать в + // конфиг руками, в том числе тот же, что у первого, — и тогда он + // показал бы чужие цифры под своим именем. Первого такой проверкой не + // ограничиваем: без найденного `claude` состояние домов неизвестно, а + // гасить из-за этого панель, работавшую до всякой второй учётки, — хуже. + if account == .secondary, !status.loggedIn { return SignedOutCredentials() } + + return HomeKeychainCredentials( + home: location.home, + fileURL: location.credentialsFileURL, + // Принадлежность приписываем снаружи: в записи Keychain её с + // 2.1.251 нет, а без неё отсечка счёта не заметит смены аккаунта. + organizationUuid: status.organizationId + ) } } diff --git a/Sources/ClaudeWeekCore/Keychain.swift b/Sources/ClaudeWeekCore/Keychain.swift index 38b227e..dffbe6d 100644 --- a/Sources/ClaudeWeekCore/Keychain.swift +++ b/Sources/ClaudeWeekCore/Keychain.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import Security @@ -5,22 +6,27 @@ import Security /// токен, которым уже пользуется Claude Code на этой машине, и потому видит /// ровно тот аккаунт, что показывает `/usage`. /// -/// Форма записи (проверено на 2.1.221, 2026-08-04): +/// Форма записи (проверено на 2.1.251, 2026-08-31): /// ``` -/// { "claudeAiOauth": { "accessToken": "sk-ant-oat01-…", "expiresAt": 1786…, -/// "refreshToken": …, "scopes": […], -/// "subscriptionType": … }, -/// "organizationUuid": … } +/// { "claudeAiOauth": { "accessToken": "sk-ant-oat01-…", "expiresAt": 1788…, +/// "refreshToken": …, "refreshTokenExpiresAt": …, +/// "scopes": […], "subscriptionType": …, +/// "rateLimitTier": … } } /// ``` +/// На 2.1.221 рядом с `claudeAiOauth` лежал ещё и `organizationUuid`; в 2.1.251 +/// его нет ни на одном уровне. Кому принадлежит токен, запись больше не +/// говорит — это знает только `claude auth status`. public struct OAuthCredentials: Sendable, Equatable { public let accessToken: String /// Момент истечения; nil — поля не было. public let expiresAt: Date? public let subscriptionType: String? - /// Организация, которой принадлежит аккаунт. Единственное в записи, что - /// переживает обновление токена: сам токен меняется раз в час, и отличить - /// по нему рабочий вход от домашнего нельзя, а этот UUID держится, пока - /// не вошли другим аккаунтом. + /// Организация, которой принадлежит аккаунт. Отличить аккаунт по самому + /// токену нельзя — он меняется раз в час, — а этот UUID держится, пока не + /// вошли другим аккаунтом. + /// + /// В записи Keychain его с 2.1.251 нет, поэтому приходит он обычно + /// снаружи, от `claude auth status`, — см. `attributed(to:)`. public let organizationUuid: String? public init( @@ -50,6 +56,17 @@ public struct OAuthCredentials: Sendable, Equatable { guard let organizationUuid, !organizationUuid.isEmpty else { return nil } return "\(organizationUuid.prefix(8))·\(subscriptionType ?? "—")" } + + /// Те же креды, но с проставленной принадлежностью. Нужно там, где + /// организацию назвал не Keychain, а `claude auth status`. + public func attributed(to organizationUuid: String) -> OAuthCredentials { + OAuthCredentials( + accessToken: accessToken, + expiresAt: expiresAt, + subscriptionType: subscriptionType, + organizationUuid: organizationUuid + ) + } } public protocol CredentialsSource: Sendable { @@ -246,110 +263,72 @@ public struct KeychainCredentials: CredentialsSource { } } -/// Креды аккаунта, имя записи которого заранее не известно. +/// Креды конфиг-дома, чья запись Keychain названа не по умолчанию. +/// +/// Аккаунты Claude Code различает конфиг-дом, и токен каждого дома лежит в +/// своей записи. Имя записи Claude Code собирает из пути дома: +/// `Claude Code-credentials-<первые 8 hex sha256 пути>`, а стандартному дому +/// оставляет имя без приставки (наблюдалось на 2.1.251: +/// `sha256("/Users/greem4/.claude-b")` начинается на `034e8c6f`, и запись +/// второго дома называется `Claude Code-credentials-034e8c6f`). +/// +/// Раньше запись искали иначе — перебором всех записей Claude Code со +/// сверкой `organizationUuid` внутри каждой. Способ умер вместе с полем: +/// в 2.1.251 запись состоит из одного `claudeAiOauth`, и `organizationUuid` +/// в ней нет ни на одном уровне. Сверять стало нечем, и сопоставление по +/// организации молча переставало находить хоть что-нибудь — второй аккаунт +/// показывал «в этот аккаунт ещё не вошли», хотя вход был. /// -/// У второго конфиг-дома Claude Code заводит свою запись Keychain, но её имя -/// собирает внутри себя и нигде не публикует. Выводить его формулой значило бы -/// закрепить догадку о чужой внутренней детали: она сменится с обновлением -/// CLI, и виджет молча покажет цифры не того аккаунта — худший исход из -/// возможных. Поэтому имя не выводится, а находится: среди записей Claude Code -/// берётся та, чей `organizationUuid` совпал с тем, что назвал -/// `claude auth status` для этого дома. Совпадение проверяет машина, а -/// неоднозначность честно оборачивается «нет доступа», а не случайным выбором. -public struct ResolvedKeychainCredentials: CredentialsSource { - /// Организация нужного аккаунта — из `claude auth status`. - private let organizationId: String +/// Имя по формуле — догадка о чужой внутренней детали, и это осознанная +/// цена. Опасность, из-за которой формулу отвергали раньше, здесь не +/// возникает: имя выводится из **дома**, а не из аккаунта, поэтому смена +/// схемы именования в CLI даёт «записи нет» — честное «нет доступа», — а не +/// чужой токен под нашим именем. +public struct HomeKeychainCredentials: CredentialsSource { + /// Конфиг-дом аккаунта — то же, что уходит в `CLAUDE_CONFIG_DIR`. + private let home: URL /// Запасной путь: креды файлом в самом доме аккаунта. private let fileURL: URL + /// Чей это дом — организация из `claude auth status`. + /// + /// Приписывается снаружи, потому что в самой записи принадлежности + /// больше нет, а `accountMark` без неё не собрать: отсечка счёта + /// перестала бы замечать вход другим аккаунтом. + private let organizationUuid: String? - public init(organizationId: String, fileURL: URL) { - self.organizationId = organizationId + public init(home: URL, fileURL: URL, organizationUuid: String? = nil) { + self.home = home self.fileURL = fileURL + self.organizationUuid = organizationUuid } public func load() throws -> OAuthCredentials { - if let data = try? Data(contentsOf: fileURL), - let credentials = try? ResolvedKeychainCredentials.parse(data), - credentials.organizationUuid == organizationId { - return credentials - } - guard let service = ResolvedKeychainCredentials.service(for: organizationId) else { - throw UsageError.unauthorized - } - return try KeychainCredentials(service: service, fileURL: fileURL).load() + let credentials = try KeychainCredentials( + service: HomeKeychainCredentials.service(for: home), + fileURL: fileURL + ).load() + // Своё в записи всегда важнее приписанного: вернётся поле в запись — + // возьмём его, и приписывать станет нечего. + guard credentials.organizationUuid == nil, let organizationUuid else { return credentials } + return credentials.attributed(to: organizationUuid) } - /// Имя записи, принадлежащей этой организации. nil — записи нет, читать её - /// не дают или подходящих оказалось несколько. - public static func service(for organizationId: String) -> String? { - let matching = candidates().filter { service in - guard let data = KeychainCredentials.readViaSecurityTool(service: service), - let credentials = try? parse(data) - else { return false } - return credentials.organizationUuid == organizationId - } - guard matching.count == 1 else { - if matching.count > 1 { - Log.warn("записей Keychain с организацией \(organizationId) больше одной — не выбираю наугад") - } - return nil - } - return matching[0] - } - - /// Имена записей Claude Code в связке. Перечисление атрибутов пароля не - /// открывает, поэтому диалога доступа здесь не бывает. - static func candidates() -> [String] { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/security") - process.arguments = ["dump-keychain"] - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - } catch { - Log.debug("security dump-keychain не запустился: \(error.localizedDescription)") - return [] - } - let output = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0 else { return [] } - return services(in: String(decoding: output, as: UTF8.self)) - } - - /// Имена записей Claude Code в выводе `security dump-keychain`. + /// Имя записи Keychain, в которой Claude Code держит токен этого дома. /// - /// Вынесено отдельно от запуска процесса, чтобы разбор проверялся тестом: - /// формат вывода — чужой и недокументированный, и ошибка в нём означала бы - /// не пустой список, а взятую наугад чужую запись. - public static func services(in output: String) -> [String] { - var names: [String] = [] - for line in output.split(separator: "\n") { - guard let open = line.range(of: "\"svce\"=\"")?.upperBound, - let close = line.lastIndex(of: "\""), - open < close - else { continue } - let name = String(line[open.. String { + let base = KeychainCredentials.defaultService + let path = home.standardizedFileURL.path + guard path != AccountLocation.defaultHome.standardizedFileURL.path else { return base } + return "\(base)-\(digest(path))" } - static func parse(_ data: Data) throws -> OAuthCredentials { - guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let token = KeychainCredentials.findToken(root), !token.isEmpty - else { throw UsageError.unauthorized } - let oauth = root["claudeAiOauth"] as? [String: Any] - return OAuthCredentials( - accessToken: token, - expiresAt: KeychainCredentials.date(from: oauth?["expiresAt"]), - subscriptionType: oauth?["subscriptionType"] as? String, - organizationUuid: (root["organizationUuid"] as? String) - ?? (oauth?["organizationUuid"] as? String) - ) + /// Первые четыре байта sha256 пути, шестнадцатеричной строкой. + static func digest(_ path: String) -> String { + SHA256.hash(data: Data(path.utf8)) + .prefix(4) + .map { String(format: "%02x", $0) } + .joined() } } diff --git a/Sources/ClaudeWeekTests/AccountTests.swift b/Sources/ClaudeWeekTests/AccountTests.swift index e654f8f..9058261 100644 --- a/Sources/ClaudeWeekTests/AccountTests.swift +++ b/Sources/ClaudeWeekTests/AccountTests.swift @@ -79,7 +79,7 @@ func runAccountTests(_ t: Harness) { t.equal(signedOut.title(fallback: "Второй"), "Второй", "без адреса подпись порядковая") // Мусор вместо ответа не должен читаться как удачный вход: иначе - // второй аккаунт пошёл бы искать запись Keychain с организацией nil. + // второй аккаунт полез бы в Keychain, ничего не зная о своём доме. t.check(!AccountDirectory.parse(Data("не json".utf8)).loggedIn, "мусор — не вход") t.check(!AccountDirectory.parse(Data()).loggedIn, "пустой ответ — не вход") @@ -88,25 +88,49 @@ func runAccountTests(_ t: Harness) { t.equal(noEmail.title(fallback: "Второй"), "max", "без адреса подписью служит тариф") } - t.suite("поиск записи Keychain") { - // Форма вывода `security dump-keychain`: имя сервиса среди прочих - // атрибутов. Берём только записи Claude Code — в связке пользователя - // лежат сотни чужих. - let dump = """ - "svce"="Claude Code-credentials" - "acct"="greem4" - "svce"="Claude Safe Storage" - "svce"="GitHub-credentials" - "svce"="Claude Code-a1b2c3-credentials" - "svce"="Claude Code-credentials" - """ - let found = ResolvedKeychainCredentials.services(in: dump) - t.equal(found.count, 2, "нашлись обе записи Claude Code и ни одной лишней") - t.check(found.contains("Claude Code-credentials"), "запись первого аккаунта найдена") - t.check(found.contains("Claude Code-a1b2c3-credentials"), "запись второго аккаунта найдена") - t.check(!found.contains("Claude Safe Storage"), "запись без -credentials отброшена") - t.check(!found.contains("GitHub-credentials"), "чужая запись отброшена") - t.equal(ResolvedKeychainCredentials.services(in: "").count, 0, "пустой вывод — пустой список") + t.suite("имя записи Keychain") { + // Вектор снят с живой машины на Claude Code 2.1.251: дом + // /Users/greem4/.claude-b держит токен в записи + // «Claude Code-credentials-034e8c6f». Тест сторожит ровно это + // соответствие — разойдётся наша формула с тем, как называет записи + // CLI, и мы узнаем здесь, а не по надписи «в этот аккаунт ещё не + // вошли» у аккаунта, в который человек вошёл. + t.equal( + HomeKeychainCredentials.service(for: URL(fileURLWithPath: "/Users/greem4/.claude-b")), + "Claude Code-credentials-034e8c6f", + "имя записи выведено из пути дома" + ) + t.equal( + HomeKeychainCredentials.service(for: AccountLocation.defaultHome), + KeychainCredentials.defaultService, + "стандартному дому достаётся имя, которое Claude Code завёл при установке" + ) + + let second = AccountLocation.expand("~/.claude-b") + t.equal( + HomeKeychainCredentials.service(for: AccountLocation.expand("~/.claude-b/")), + HomeKeychainCredentials.service(for: second), + "хвостовой слеш не уводит к несуществующей записи" + ) + t.check( + HomeKeychainCredentials.service(for: second) + != HomeKeychainCredentials.service(for: AccountLocation.expand("~/.claude-c")), + "разным домам достаются разные записи" + ) + } + + t.suite("метка аккаунта") { + // Организация в записи Keychain больше не лежит, и метку собирает то, + // что сказал `claude auth status`. Форма при этом обязана остаться + // прежней: в state.json работающих установок записано «2a420a73·pro», + // и смена источника не должна сойти за смену аккаунта — иначе счёт + // обнулился бы у всех разом на первом же обновлении. + let bare = OAuthCredentials(accessToken: "t", expiresAt: nil, subscriptionType: "pro") + t.equal(bare.accountMark, nil, "без организации метки нет") + + let marked = bare.attributed(to: "2a420a73-2872-4640-a99e-ed027d474338") + t.equal(marked.accountMark, "2a420a73·pro", "метка собрана из организации и тарифа") + t.equal(marked.accessToken, "t", "токен пережил приписывание") } t.suite("креды аккаунта") { @@ -135,14 +159,24 @@ func runAccountTests(_ t: Harness) { config: config, status: AccountStatus(loggedIn: true, organizationId: "org-2") ) - t.check(signedIn is ResolvedKeychainCredentials, "вошедший второй ищет свою запись по организации") + t.check(signedIn is HomeKeychainCredentials, "вошедший второй читает запись своего дома") + t.check( + HomeKeychainCredentials.service(for: AccountLocation(account: .secondary, config: config).home) + != KeychainCredentials.defaultService, + "и это не запись первого аккаунта" + ) let primary = ResolvingProvider.credentials( for: .primary, config: config, status: .signedOut ) - t.check(primary is KeychainCredentials, "первый аккаунт читает запись с известным именем") + t.check(primary is HomeKeychainCredentials, "первый аккаунт читает запись своего дома") + t.equal( + HomeKeychainCredentials.service(for: AccountLocation(account: .primary, config: config).home), + KeychainCredentials.defaultService, + "и у стандартного дома это прежнее имя записи" + ) } t.suite("аккаунт в конфиге") { diff --git a/docs/API.md b/docs/API.md index 4cfff0c..574a0c5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -62,16 +62,22 @@ found or is malformed`; заголовки `anthropic-beta: oauth-2025-04-20` и "rateLimitTier": "…", "scopes": ["…"], "subscriptionType": "max" - }, - "organizationUuid": "…" + } } ``` `expiresAt` — миллисекунды (значения меньше `1e11` трактуем как секунды). Токен живёт около часа. -`organizationUuid` читается тоже — из него и `subscriptionType` складывается -**метка аккаунта** (`OAuthCredentials.accountMark`, вида `7f3a1b2c·max`). Токен +**Кому принадлежит токен, запись больше не говорит.** На 2.1.221 рядом с +`claudeAiOauth` лежал `organizationUuid`; на 2.1.251 его нет ни на одном +уровне записи. Поле читается по-прежнему — вернётся, возьмём его, — но +принадлежность приписывает снаружи `HomeKeychainCredentials`: организацию +называет `claude auth status` для этого конфиг-дома, тариф берётся из самой +записи. + +Из них и складывается **метка аккаунта** (`OAuthCredentials.accountMark`, вида +`7f3a1b2c·max`). Токен меняется раз в час, а она держится, пока не вошли другим аккаунтом, — и это единственное, чем смена аккаунта замечается вообще: в транскриптах `~/.claude/projects` маркера аккаунта нет, а сервер отвечает по токену и о @@ -79,6 +85,15 @@ found or is malformed`; заголовки `anthropic-beta: oauth-2025-04-20` и ([USAGE.md](USAGE.md#смена-аккаунта)). Метка в `state.json` хранится целиком, сам UUID — нет: сравнивать хватает её. +**Имя записи задаёт конфиг-дом.** Стандартному дому достаётся +`Claude Code-credentials`, любому другому — то же имя с приставкой из первых +восьми hex-символов sha256 пути дома: `/Users/…/.claude-b` → +`Claude Code-credentials-034e8c6f` (проверено на 2.1.251). Формула сторожится +тестом с этим самым вектором. Она — догадка о чужой внутренней детали, и цена +её названа честно: сменится схема именования в CLI — записи не найдётся, и +аккаунт покажет «нет доступа». Чужой токен под своим именем при этом не +всплывёт: имя выводится из дома, а не из аккаунта. + **Обновлением токена ClaudeWeek не занимается.** Refresh-цикл — дело Claude Code; два процесса, наперегонки меняющие одну запись Keychain, теряют токен. Мы перечитываем запись перед каждым запросом и на `401` уходим на локальную diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 66e4610..af71eb3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -82,9 +82,10 @@ SwiftUI` — значит, расчёт просочился в UI или нао - **«Откуда программа знает про второй аккаунт»** → `Accounts.swift`: `AccountLocation` раскладывает конфиг-дом на пути (транскрипты, кеш, отсечка, индекс), `AccountDirectory` спрашивает `claude auth status`, кто в этот дом - вошёл. Имя записи Keychain второго дома Claude Code нигде не публикует, - поэтому оно не выводится формулой, а находится по организации — - `ResolvedKeychainCredentials`. Аккаунту без входа достаётся + вошёл. Токен берёт `HomeKeychainCredentials`: имя записи Keychain Claude Code + собирает из пути дома, и мы собираем так же — стандартному дому + `Claude Code-credentials`, прочим то же имя с приставкой из первых восьми + hex-символов sha256 пути. Аккаунту без входа достаётся `SignedOutCredentials`, а не `nil`: `nil` в `ResolvingProvider` означает «Keychain по умолчанию», то есть запись первого аккаунта. @@ -421,7 +422,7 @@ swift build # оба таргета # объявления: ключи kSecUseAuthenticationUI в Keychain.swift оставлены # намеренно, замены им нет, и группа понижена обратно до предупреждения. swift build -Xswiftc -warnings-as-errors -Xswiftc -Wwarning -Xswiftc DeprecatedDeclaration -swift run ClaudeWeekTests # 538 проверок, без сети и без UI +swift run ClaudeWeekTests # 541 проверка, без сети и без UI swift run ClaudeWeekApp # запустить из исходников (появится вторая иконка!) ./scripts/signing-cert.sh # один раз: постоянный сертификат подписи ./scripts/make-app.sh # собрать dist/ClaudeWeek.app @@ -619,4 +620,5 @@ Intel — собирает у себя, `install.sh` соберёт нативн | `~/Library/LaunchAgents/com.greem4.claudeweek.plist` | автозапуск | | `~/Applications/ClaudeWeek.app` | установленная копия | | Keychain `Claude Code-credentials` | токен Claude Code первого аккаунта (только читаем) | -| Keychain, запись второго аккаунта | имя Claude Code собирает внутри себя и не публикует. Не угадываем: среди записей `*-credentials` берём ту, чей `organizationUuid` совпал с `orgId` из `claude auth status` для этого дома. Совпадений не одно — читаем «нет доступа», а не выбираем наугад | +| Keychain, запись второго аккаунта | имя Claude Code собирает из пути конфиг-дома и не публикует: `Claude Code-credentials-<первые 8 hex sha256 пути>` (2.1.251). Собираем так же и сторожим тестом с живым вектором. Прежний способ — искать запись по `organizationUuid` внутри неё — умер вместе с полем: в 2.1.251 запись состоит из одного `claudeAiOauth` | +| Принадлежность токена | из записи Keychain она пропала (см. выше), и называет её только `claude auth status` — `orgId` для этого дома. Из него и `subscriptionType` собирается метка, по которой замечают вход другим аккаунтом | diff --git a/docs/PLAN.md b/docs/PLAN.md index e7c352c..e124584 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -653,7 +653,7 @@ assert-хелперов, запускаемый `swift run ClaudeWeekTests`; н = падение. Когда/если появится Xcode, таргет переносится на swift-testing без изменения самих проверок. -Что покрыто (477 проверок, `swift run ClaudeWeekTests`): +Что покрыто (541 проверка, `swift run ClaudeWeekTests`): 1. **Окно недели.** Момент ровно на сбросе; за минуту до и после; полсекунды до; воскресный полдень; смена таймзоны между вызовами; другой день сброса. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5f05466..364bcf3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -324,5 +324,5 @@ requirement не менялся и приложение держалось в с | Было | Стало | |---|---| -| виджет показывал ровно один аккаунт — тот, под которым сейчас работает Claude Code, — и поставить рядом рабочий с личным было нельзя. Прежние цифры при переключении не сохранялись, а отбрасывались (бывший пункт 10) | два аккаунта в панели. Claude Code различает их конфиг-домом, и второй дом (`~/.claude-b` плюс `CLAUDE_CONFIG_DIR`) держит свой токен, не выбивая первый. Появился каталог — в заголовке встают два знака Claude; выбранный горит оранжевым, второй гаснет до серого, а кто из них кто, говорит подсказка с живым адресом из `claude auth status`. Состояние хранится по аккаунту, а не одним файлом: у второго свои `secondary-cache.json`, `secondary-alerts.json`, `secondary-state.json` и `secondary-index.json`, поэтому обратное переключение мгновенно и ничего не теряет. Имя записи Keychain второго дома Claude Code не публикует — оно не выводится формулой, а находится по организации, которую назвал `claude auth status`. Каталога нет — нет и переключателя. Осталось за бортом: аккаунтов ровно два, произвольного числа нет — под него пришлось бы заводить список в конфиге, а спроса на третий пока не видно | +| виджет показывал ровно один аккаунт — тот, под которым сейчас работает Claude Code, — и поставить рядом рабочий с личным было нельзя. Прежние цифры при переключении не сохранялись, а отбрасывались (бывший пункт 10) | два аккаунта в панели. Claude Code различает их конфиг-домом, и второй дом (`~/.claude-b` плюс `CLAUDE_CONFIG_DIR`) держит свой токен, не выбивая первый. Появился каталог — в заголовке встают два знака Claude; выбранный горит оранжевым, второй гаснет до серого, а кто из них кто, говорит подсказка с живым адресом из `claude auth status`. Состояние хранится по аккаунту, а не одним файлом: у второго свои `secondary-cache.json`, `secondary-alerts.json`, `secondary-state.json` и `secondary-index.json`, поэтому обратное переключение мгновенно и ничего не теряет. Имя записи Keychain второго дома Claude Code не публикует, но собирает его из пути дома (`Claude Code-credentials-<первые 8 hex sha256 пути>`) — ClaudeWeek повторяет ту же формулу, и сторожит её тест с живым вектором. Искать запись по `organizationUuid` внутри неё, как было задумано сперва, больше нельзя: 2.1.251 убрал это поле из записи, и принадлежность токена теперь называет только `claude auth status`. Каталога нет — нет и переключателя. Осталось за бортом: аккаунтов ровно два, произвольного числа нет — под него пришлось бы заводить список в конфиге, а спроса на третий пока не видно | | `claude auth status` спрашивали всегда с `CLAUDE_CONFIG_DIR`, и вкладка «Доступ» показывала «не вошли» у аккаунта, в котором человек работает прямо сейчас | переменная ставится только для нестандартного дома. Выставленная даже в тот же самый путь `~/.claude`, она заставляет Claude Code отвечать `loggedIn: false` — ячейку учётных данных выбирает не путь, а факт установки переменной (2.1.251). Унаследованная из окружения снимается: приложение могли запустить из оболочки под вторым аккаунтом. Грабли записаны в [ARCHITECTURE.md](ARCHITECTURE.md#грабли) | diff --git a/docs/USAGE.md b/docs/USAGE.md index d818ba9..c1dcc6e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -183,13 +183,21 @@ Claude**. Выбранный аккаунт горит фирменным ора ### Откуда берётся токен второго аккаунта -Запись Keychain второго дома Claude Code называет по-своему и это имя нигде не -публикует. Выводить его формулой значило бы закрепить догадку о чужой -внутренней детали: она сменится с обновлением CLI, и виджет молча покажет -цифры не того аккаунта. Поэтому имя не выводится, а **находится**: среди -записей Claude Code берётся та, чей `organizationUuid` совпал с `orgId`, -который назвал `claude auth status` для этого дома. Совпадений оказалось не -одно — виджет честно скажет «нет доступа», а не выберет наугад. +Токен каждого конфиг-дома лежит в своей записи Keychain, а имя записи Claude +Code собирает из пути дома: стандартному достаётся `Claude Code-credentials`, +любому другому — то же имя с приставкой из первых восьми hex-символов sha256 +пути (`~/.claude-b` → `Claude Code-credentials-034e8c6f`). Имя это нигде не +публикуется, так что ClaudeWeek повторяет формулу — и это осознанная догадка о +чужой внутренней детали: сменится схема именования в CLI, и второй аккаунт +скажет «в этот аккаунт ещё не вошли», пока формулу не поправят. Зато чужих +цифр под своим именем он не покажет: имя выводится из дома, а не из аккаунта, +поэтому промах формулы даёт «записи нет», а не чужой токен. + +Раньше запись искали иначе — перебором записей Claude Code со сверкой +`organizationUuid` внутри каждой. Способ умер вместе с полем: на 2.1.251 +запись состоит из одного `claudeAiOauth`, организации в ней нет вовсе, и +сверять стало не с чем. Кому принадлежит токен, знает теперь только +`claude auth status`. По той же причине аккаунт, в который не вошли, не получает «креды по умолчанию»: иначе он показал бы цифры первого аккаунта под своим именем.