Shows the 5h and 7d rate-limit windows and the session cost in the status line, with a /usage-meter pane and a toast when a window runs hot.

Dieses Repository verwaltet dieselbe Benutzerumgebung aus einer gemeinsamen Flake für:
Benutzername, Plattformen, Home-Verzeichnisse, Hostname und Checkout-Pfade stehen ausschließlich in machine-config.nix. Die Konfiguration ist in kleine Dateien unter modules/ aufgeteilt.
Homebrew und Determinate Nix sind die einmaligen Voraussetzungen:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | \
sh -s -- install
Rosetta wird für diese Konfiguration nicht benötigt. Der Intel-Homebrew-Prefix ist deaktiviert; ein bestehendes /usr/local wird weder verändert noch gelöscht.
Danach das Repository an den in machine-config.nix hinterlegten Ort klonen und die Maschinenwerte prüfen:
mkdir -p ~/.config
cd ~/.config
git clone https://github.com/sadomasupilami/nix-darwin.git
cd nix-darwin
Vor einer Aktivierung immer den read-only Preflight ausführen:
./config/nix-config/nix-config-preflight
Er baut die Konfiguration, wertet ein nicht leeres Brewfile aus, schützt chatgpt und Minutes, parst alle installierten Formeln und Casks, prüft Homebrew Bundle und zeigt mit --all --zap die geplante Bereinigung ohne --force. Dafür verwendet er bereits das aus dem gemeinsamen Lockfile gebaute Homebrew und nicht die eventuell noch ältere Live-Installation. Der Preflight führt weder Upgrades noch Löschungen oder eine Aktivierung aus.
nix-homebrew.mutableTaps = false benötigt /opt/homebrew/Library/Taps als Nix-verwalteten Symlink. Solange dort das bisherige echte Verzeichnis liegt, stoppt der Preflight absichtlich. Die Migration ist nicht automatisiert:
Beispiel für Schritt 2, bewusst nicht automatisch ausgeführt:
sudo mv /opt/homebrew/Library/Taps \
/opt/homebrew/Library/Taps.before-nix-homebrew-YYYYMMDD-HHMMSS
Der empfohlene erste Apply führt den Preflight nochmals aus und verwendet intern den im gemeinsamen Lockfile gepinnten darwin-rebuild:
./config/nix-config/nix-config-apply
Der zugrunde liegende Flake-Einstieg ist sudo -- "$(command -v nix)" run "$snapshot#darwin-rebuild" -- switch --flake "$snapshot#macos". $snapshot ist dabei der zuvor geprüfte Nix-Store-Snapshot. sudo ist für die Systemaktivierung erforderlich; die App und ihre Version stammen weiterhin aus dem gemeinsamen Lockfile.
Ein echter Apply bleibt eine gesondert zu bestätigende Aktion. Er aktualisiert deklarierte Homebrew- und App-Store-Apps und entfernt mit cleanup = "zap" nicht deklarierte Homebrew-Apps samt Zap-Daten. Ein Nix-Rollback stellt weder Homebrew-Upgrades noch gezappte Daten wieder her.
Nach der ersten Home-Manager-Aktivierung liegen diese Befehle in ~/.bin. Ihre Implementierung und Hilfsprogramme kommen aus dem Nix Store; Änderungen im Checkout ändern die installierten Helfer erst beim nächsten Apply:
nix-config-update [all|homebrew]
nix-config-preflight
nix-config-apply [--gc] [--accept-zap HASH]
nix-config-update all aktualisiert alle Root-Flake-Inputs und führt danach den Preflight aus.nix-config-update homebrew aktualisiert nur nix-homebrew, Core, Cask sowie die Azure- und Silverstein-Taps als gemeinsame Gruppe und führt danach den Preflight aus.flake.lock zur Prüfung liegen, blockiert aber den Apply.configurationRevision an die Generation weiter; ein Checkout mit lokalen Änderungen bleibt unbeschriftet.azd bleibt explizit azure/azd/azd; Minutes verwendet wegen des gleichnamigen Formula- Eintrags einen separaten Cask-Installationspfad.nix-config-apply --accept-zap HASH mit dem ausgegebenen Hash ausgeführt werden. Die Freigabe gilt nur für genau diese Liste und diesen Snapshot; geänderte Dateien oder eine andere Entfernungsliste benötigen eine neue Freigabe. Während des Apply keine parallelen Homebrew-Änderungen vornehmen.nix-config-apply --gc führt ein vollständiges nix-collect-garbage aus, weiterhin ohne Löschen alter Generationen und ohne Schonfrist für kürzlich entstandene Pfade (siehe unten). Ein separates mas upgrade gibt es nicht mehr.git add getrackt werden; ignorierte und ungetrackte Dateien gehören bewusst nicht zum Snapshot.Der Determinate-Nixd-eigene Collector ist abgeschaltet (garbageCollector.strategy = "disabled"), weil er keine Aufbewahrungsfrist kennt. An seiner Stelle prüft der LaunchDaemon nix-gc-recent (modules/nix-gc.nix, Skript in config/nix-gc/) stündlich den freien Platz auf /nix:
nix-store --gc ausgeführt, aber unerreichbare Store-Pfade, die jünger als 14 Tage sind, bleiben samt ihren Abhängigkeiten erhalten. Dafür legt das Skript für die Dauer des Laufs temporäre GC-Roots unter /nix/var/nix/gcroots/nix-gc-recent an./nix ist mit noatime eingehängt, ein Pfad trägt also nur den Zeitpunkt seiner Registrierung. Die Schonfrist schützt daher kürzlich gebaute oder geladene Pfade, nicht kürzlich ausgeführte. Protokoll: /var/log/nix-gc-recent.log. Prüfen ohne Root und ohne Änderung:
nix-gc-recent --force --dry-run
Schonfrist und Schwellen stehen oben in modules/nix-gc.nix.
Homebrew-Casks und App-Store-Apps bleiben bewusst nativ verwaltet. Gepinnte Tap-Quellen fixieren die Paketdefinitionen; App-eigene Updater und App-Store- Versionen können davon unabhängig sein. Nix-Rollbacks rollen diese Apps nicht zurück.
Die gewählte Homebrew-Politik bleibt autoUpdate = false, upgrade = true und cleanup = "zap". Die Homebrew-Version wird aus flake.lock abgeleitet. chatgpt ist der aktuelle Desktop-Weg mit integriertem Codex. Das Verhalten der unforced Cleanup-Vorschau entspricht der Homebrew-Manpage.
mas erkennt installierte App-Store-Apps über Spotlight. Der read-only Preflight unterbindet die automatische Indexierung durch mas; meldet er eine vorhandene und deklarierte App trotzdem als fehlend, muss der Spotlight-Index repariert werden, nicht die App zusätzlich als Cask deklariert werden. Siehe die Spotlight-Hinweise von mas und Apples Anleitung zum Neuaufbau des Index.
Nach der Nix-Installation das Repository an den Linux-Pfad aus machine-config.nix klonen. Die verschachtelte Flake und ihr zweites Lockfile gibt es nicht mehr. Der erste Apply verwendet ausschließlich die im Root-Lock festgelegte Home-Manager-Version:
cd ~/.config/nix-darwin
nix run .#home-manager -- switch --flake .#default
Dieselben Helfer stehen anschließend zur Verfügung. Unter Linux überspringt der Preflight nur die macOS-/Homebrew-Prüfungen; die Root-Flake wird weiterhin geprüft.
Die öffentlichen Flake-Ausgaben sind:
darwinConfigurations.macos
homeConfigurations.default
homeConfigurations.michaelklug
modules/claude.nix verlinkt config/claude/CLAUDE.md nach ~/.claude/CLAUDE.md. Claude Code lädt die Datei in jeder Session. Sie sagt Claude, dass fehlende Tools per nix shell nixpkgs#<paket> geholt werden statt global installiert.
Der Flake-Input claude-code-playground pinnt die offiziellen Sample-Mods von Anthropic in flake.lock. modules/claude-mods.nix verlinkt token-weather (Kontextfenster-Prognose über dem Prompt) und blast-radius (hält riskante Shell-Befehle an und zeigt vorab, was sie ändern würden) nach ~/.claude/skills. Dazu kommt der eigene Mod usage-meter aus config/claude-mods/usage-meter/: Er zeigt die Rate-Limit-Fenster (5h, 7d) und die Session-Kosten in der Statuszeile, mit /usage-meter als Pane mit Balken und Reset-Zeiten, und warnt per Toast ab 80 % und 95 %. Claude Code lädt diese Ordner in jeder Session automatisch als Plugin <name>@skills-dir; ~/.claude/settings.json bleibt unangetastet. Tests für den eigenen Mod: claude plugin test config/claude-mods/usage-meter.
Mods brauchen Claude Code ab 2.1.287. Dafür installiert Homebrew das Cask claude-code@latest statt des langsameren Stable-Casks claude-code. Mods laufen mit den eigenen Rechten und ohne Sandbox. Vor einem Pin-Update claude plugin validate auf die neuen Store-Pfade anwenden und die hooks:- und calls:-Zeilen prüfen. Update mit nix flake update claude-code-playground oder nix-config-update all, danach Preflight und Apply. Kontrolle mit claude plugin list.
Auf Apple Silicon installiert Home Manager die Befehle qwen-meeting und qwen-meeting-setup samt unveränderlicher Python-Laufzeit im Nix Store. packages/qwen-meeting-runtime bezieht die Python-3.13-Wheels aus dem committed uv.lock mit ihren Hashes; Installation und Abhängigkeitsprüfung im Paket erfolgen offline. Nix lädt die Wheels beim Build. Die Wrapper referenzieren die Laufzeit ihrer Generation, sodass ein Rollback auch die Python-Abhängigkeiten wiederherstellt.
Nur die großen Modellgewichte werden durch den ausdrücklichen Setup-Aufruf geladen:
qwen-meeting-setup
Er lädt mit der Nix-Laufzeit exakt diese Modellrevisionen:
7278e1e70fe206f11671096ffdd38061171dd6e5c7cbfc2048c462b0d63a45797104fc9db3ad62b7Eine vorhandene ~/.venvs/qwen3-asr wird weder verändert noch entfernt und von den neuen Wrappern nicht mehr verwendet. Modelle und Transkripte bleiben außerhalb des Nix Stores. Die Modellrevisionen werden weiterhin separat durch Setup verwaltet; ein Runtime-Rollback lädt oder ersetzt keine Modellgewichte.
Transkriptionsläufe verwenden danach nur lokale Modellpfade und HF_HUB_OFFLINE=1:
qwen-meeting "/vollständiger/Pfad/aufnahme.m4a" [auto|de|en]
qwen-meeting --copy "/vollständiger/Pfad/aufnahme.m4a" auto
Der Ausgabeordner öffnet sich standardmäßig im Finder. Das Transkript wird nicht ins Terminal geschrieben und nur mit --copy in die Zwischenablage kopiert. Neue Verzeichnisse erhalten Modus 0700, neue Dateien 0600. Vorhandene unsichere Verzeichnisse werden mit einem Hinweis abgelehnt und nicht automatisch umgestellt. Das verwaltete Vokabular liegt in config/qwen-meeting/context.txt.
Die persönliche Kontaktliste bleibt bewusst außerhalb von Git und Nix Store:
~/.config/raycast/teams-people.csv
name,email,tenantId
Das Verzeichnis muss 0700, die CSV 0600 haben. Das Skript ändert bestehende Rechte nicht rückwirkend. Es protokolliert weder Namen noch E-Mail-Adressen, Tenant-IDs oder Deep Links. Statt des früheren globalen /tmp-Logs schreibt es nur eine generische, atomar ersetzte Statuszeile in eine private Datei. Das alte /tmp/raycast-teams-video-call.log wird nicht automatisch gelöscht.
Lokal:
nix flake check --all-systems --no-build
nix flake check
bash tests/run-local-tools-tests.sh
bash tests/nix-config-helpers.sh
nix fmt -- --check
git diff --check
GitHub Actions wertet und baut auf ubuntu-24.04 und macos-26. CI hat nur contents: read und führt keine Systemaktivierung, Homebrew-Upgrades oder Zap-Bereinigung aus.
silverstein/tap/minutes erhalten. Die GUI erhält weiterhin MINUTES_FFMPEG aus dem Nix-Benutzerprofil.mas und Docker Credential Helpers kommen ausschließlich aus Nix.Weitere manuelle Einrichtung: Bartender-Lizenz. Pakete lassen sich über search.nixos.org suchen.
hooks/register.tsx 144 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
4import type { Snapshot, Window } from '../types'
5
6const PANE = 'usage-meter'
7const WARN = 80
8const CRITICAL = 95
9const THRESHOLDS = [WARN, CRITICAL]
10const BAR_WIDTH = 24
11
12const snapshot = atom({ plugin: 'usage-meter', key: 'snapshot' } as const, null)
13const warned = atom({ plugin: 'usage-meter', key: 'warned' } as const, {})
14
15const LABELS: Record<string, string> = {
16 five_hour: '5h',
17 seven_day: '7d',
18 spend_limit: 'Spend',
19}
20
21const label = (kind: string) => LABELS[kind] ?? kind
22
23const toSnapshot = (u: SessionUsage | SessionMeasureInput): Snapshot => ({
24 windows: u.rateLimits.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })),
25 costUsd: u.cost?.usd,
26 contextPercent: u.context.percent,
27})
28
29const usd = (n: number) => `$${n < 10 ? n.toFixed(2) : n.toFixed(1)}`
30
31export const statusText = (s: Snapshot): string | undefined => {
32 const parts = s.windows.map(w => `${label(w.kind)} ${Math.round(w.percentUsed)}%`)
33 if (s.costUsd !== undefined) parts.push(usd(s.costUsd))
34 return parts.length ? parts.join(' · ') : undefined
35}
36
37export const until = (resetsAt: string | undefined, now: number): string | undefined => {
38 const at = resetsAt ? Date.parse(resetsAt) : NaN
39 if (Number.isNaN(at)) return undefined
40 const minutes = Math.max(0, Math.round((at - now) / 60000))
41 const days = Math.floor(minutes / 1440)
42 const hours = Math.floor((minutes % 1440) / 60)
43 if (days > 0) return `${days}d ${hours}h`
44 if (hours > 0) return `${hours}h ${minutes % 60}m`
45 return `${minutes}m`
46}
47
48export const bar = (percent: number, width = BAR_WIDTH): string => {
49 const filled = Math.min(width, Math.round((Math.min(percent, 100) / 100) * width))
50 return '█'.repeat(filled) + '░'.repeat(width - filled)
51}
52
53const colorOf = (percent: number) =>
54 percent >= CRITICAL ? 'error' : percent >= WARN ? 'warning' : 'success'
55
56const levelOf = (percent: number) => THRESHOLDS.filter(t => percent >= t).pop() ?? 0
57
58const apply = async ($: EngineInterface, s: Snapshot) => {
59 await update($, snapshot, () => s)
60 $.ui.status(statusText(s))
61
62 const seen = await read($, warned)
63 const next: Record<string, number> = { ...seen }
64 for (const w of s.windows) {
65 const level = levelOf(w.percentUsed)
66 if (level > (seen[w.kind] ?? 0)) {
67 const resets = until(w.resetsAt, await $.clock.now())
68 $.ui.toast(
69 `${label(w.kind)} limit at ${Math.round(w.percentUsed)}%${resets ? `, resets in ${resets}` : ''}`,
70 { timeoutMs: 8000 },
71 )
72 }
73 // A drop below the last warned level means the window reset: warn again next time.
74 next[w.kind] = level
75 }
76 await update($, warned, () => next)
77}
78
79export const register: Register = on => {
80 on('session.start', async ($, e, next) => {
81 await $.command.register({
82 name: 'usage-meter',
83 description: 'Show rate-limit windows and session cost in a pane',
84 })
85 const result = await next(e)
86 await apply($, toSnapshot(await $.session.usage()))
87 return result
88 })
89
90 on('session.measure', async ($, e, next) => {
91 await apply($, toSnapshot(e))
92 return next(e)
93 })
94
95 on('command.run', { command: 'usage-meter' }, async $ => {
96 await apply($, toSnapshot(await $.session.usage()))
97 await $.ui.open({ id: PANE, title: 'Usage' })
98 return { text: 'Usage pane opened.' }
99 })
100
101 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
102 const { Box, Text } = $.ui.resolve(e)
103 const s = await read($, snapshot)
104 const now = await $.clock.now()
105 const width = Math.max(8, Math.min(BAR_WIDTH, (e.props.bodyColumns ?? 40) - 16))
106
107 if (!s) return <Text dimColor>No usage reading yet.</Text>
108
109 return (
110 <Box flexDirection="column">
111 {s.windows.length === 0 && (
112 <Text dimColor>No rate-limit windows (not on a subscription, or no response yet).</Text>
113 )}
114 {s.windows.map((w: Window) => {
115 const resets = until(w.resetsAt, now)
116 return (
117 <Box key={w.kind} flexDirection="column">
118 <Text>
119 <Text bold>{label(w.kind).padEnd(5)}</Text>
120 <Text color={colorOf(w.percentUsed)}>{bar(w.percentUsed, width)}</Text>
121 <Text> {Math.round(w.percentUsed)}%</Text>
122 </Text>
123 {resets && <Text dimColor>{' '}resets in {resets}</Text>}
124 </Box>
125 )
126 })}
127 {s.contextPercent !== undefined && (
128 <Text>
129 <Text bold>{'Ctx'.padEnd(5)}</Text>
130 <Text color={colorOf(s.contextPercent)}>{bar(s.contextPercent, width)}</Text>
131 <Text> {Math.round(s.contextPercent)}%</Text>
132 </Text>
133 )}
134 {s.costUsd !== undefined && (
135 <Text>
136 <Text bold>{'Cost'.padEnd(5)}</Text>
137 <Text>{usd(s.costUsd)} this session</Text>
138 </Text>
139 )}
140 </Box>
141 )
142 })
143}
144types/index.d.ts 18 lines1export type Window = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Snapshot = {
4 windows: Window[]
5 costUsd?: number
6 contextPercent?: number
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'usage-meter': {
12 snapshot: Snapshot | null
13 // Highest warning threshold already toasted, per window kind.
14 warned: Record<string, number>
15 }
16 }
17}
18