SLOPSHOPPER

usage-band

Show 5h / 1w rate limits and this session's task progress above the prompt

newbandguard
★ 9v0.1.0no licenseupdated 2026-10-09luvpame/dotfiles/config/claude/mods/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM 5h 31% ↻2:53 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h 31% ↻2:53
README

dotfiles

Reproducible macOS dotfiles for Apple Silicon.

Built with Nix, nix-darwin, Home Manager, Homebrew, and Fish.

What it manages

AreaLocation
System and packagesnix/
User and application configurationconfig/
Menu bar helpersmenubar-script/
Small utilitiesscript/

Most managed files use Home Manager out-of-store symlinks into the checkout, so edits take effect immediately. Run just switch after changing Nix packages, Homebrew applications, macOS defaults, or the set of managed files.

Requirements

Install anything missing:

xcode-select --install

curl -sSfL https://artifacts.nixos.org/nix-installer | sh -s -- install --enable-flakes

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

Restart the shell after installing Nix so that nix is available.

Setup

Clone the repository and point $HOME/.dotfiles at the checkout:

git clone https://github.com/luvpame/dotfiles.git ~/src/dotfiles
cd ~/src/dotfiles

if [ -e "$HOME/.dotfiles" ] && [ ! -L "$HOME/.dotfiles" ]; then
  echo "$HOME/.dotfiles is not a symlink; refusing to overwrite it." >&2
  exit 1
fi

ln -shf "$(pwd -P)" "$HOME/.dotfiles"

Set dotfiles.user.name in nix/nix-darwin/users.nix. This is the single user name used by the configuration.

Create the local Git configuration from config/git/config.local.example, then fill in your identity:

test -f config/git/config.local || cp config/git/config.local.example config/git/config.local
$EDITOR config/git/config.local

Set user.name, user.email, and ghq.root. Home Manager exposes this file as ~/.config/git/config.local, which the managed Git config includes. Keep it out of version control. The shared Git configuration uses Secure Enclave for commit signing, so complete the Git commit signing setup below before committing. Keep the 1Password SSH Agent enabled for push and pull.

Git commit signing

The repository includes a repeatable setup wizard for moving commit signing to a non-exportable Secure Enclave key. Run it from a normal interactive Terminal session on each Mac. sc_auth and CryptoTokenKit may not be available from an agent or another non-interactive process.

The wizard creates or validates a p-256-ne CTK identity with -t none, creates the SSH key handle pair at ~/.ssh/id_git_sign and ~/.ssh/id_git_sign.pub when needed, and runs a temporary signed commit verification. The private key never leaves the Secure Enclave, while the reference files remain device-specific.

OpenSSH may show a generic authenticator PIN prompt even for this key. The wizard answers it with an empty input; if a PIN or password is requested interactively, enter nothing and stop the wizard.

The default command performs the local setup and self-test, then prints the exact GitHub command without changing the GitHub account:

./script/setup-git-signing.sh

To let the wizard check the GitHub signing-key list and register the public key after the self-test and an explicit confirmation, use:

gh auth login
# Run this once before the first registration (or when the wizard reports a missing scope).
gh auth refresh -h github.com -s admin:ssh_signing_key
./script/setup-git-signing.sh --register-github

The wizard checks that scope before it changes GitHub. If the check reports a network or other API error, it stops without treating that error as a scope problem.

The GitHub key title uses git-sign@<short-hostname> so multiple Macs can be distinguished. If the hostname is unavailable, the title falls back to git-sign; repeated-run detection compares the public-key contents instead of the title.

The shared Git configuration now uses Secure Enclave for commit signing. On an existing Mac, run the wizard before relying on the new configuration. With --register-github, it checks GitHub API access during preflight, completes the local self-test, and then registers the key after confirmation. After the new key produces a Verified commit on GitHub, remove only the old 1Password Signing key from GitHub. Keep the 1Password SSH Agent and its authentication key for push and pull.

This migration changes commit signing only. Tag signing is unchanged. -t none deliberately avoids Touch ID or passcode prompts so an unattended Coding Agent can finish a commit; any process running as the logged-in user can therefore request a signature. Secure Enclave storage protects the private key from export, but it does not protect against malware already running in that account.

The wizard does not create a permanent allowedSignersFile. Its verification file and temporary Git repository are removed after the self-test; GitHub's Verified status is the ongoing verification record.

Sign in to the Mac App Store before switching; mas installs App Store applications as part of the configuration.

Apply the initial system configuration. just is installed by Home Manager during this step, so it is not required yet:

cd nix
sudo -H nix --extra-experimental-features "nix-command flakes" \
  run github:LnL7/nix-darwin -- switch --flake "path:.#default"
cd ..

Everyday commands

CommandPurpose
just checkValidate the flake
just buildBuild the Darwin system
just switchApply the configuration
just cleanRemove old Nix generations

just switch updates $HOME/.dotfiles to the physical path of the current checkout before applying changes, so clone locations and Git worktrees are supported.

Fish

Set Fish as the default shell after the first switch:

./script/set-fish-default.sh

Log out and back in, or start a new Fish session, then update plugins:

fish
fisher update

CI and Cachix

GitHub Actions checks and prebuilds darwinConfigurations.default.system for aarch64-darwin on macos-15, then publishes the result to the public luvpame Cachix cache. Add a repository secret named CACHIX_AUTH_TOKEN with permission to push to Cachix. The flake and nix-darwin configuration already declare luvpame.cachix.org, so local builds can use the same cache.

Pushes to main that change nix/** or this workflow build the current flake.lock. A schedule runs at 17 minutes past every hour (UTC), updates flake inputs, and builds only when flake.lock changes. A successful update commits the new lock file to main. Manual runs on main can enable force_build to rebuild the current lock even when it is unchanged.

Pull CI's updated nix/flake.lock before running just switch to use the prebuilt inputs. just us updates inputs locally and switches immediately, so its result may differ from the CI build.

Authentication

After switching, sign in to services that need authentication:

  • 1Password and 1Password CLI
  • GitHub CLI, if needed: gh auth login
Source 2 files
hooks/register.tsx 178 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Jobs, Limit } from '../types'
5
6const limits = atom({ plugin: 'usage-band', key: 'limits' } as const, {})
7const jobs = atom({ plugin: 'usage-band', key: 'jobs' } as const, { running: 0, done: 0 })
8// サブエージェントは $.agent.list() で数え、それ以外のバックグラウンド作業はツール呼び出しから拾う
9const BG_TOOLS = ['Bash', 'Monitor', 'Workflow']
10const isBackground = (tool: string, input: Record<string, unknown>) =>
11  tool === 'Bash' ? input.run_in_background === true : BG_TOOLS.includes(tool)
12
13async function refreshJobs($: EngineInterface) {
14  const started: string[] = []
15  const ended = new Set<string>()
16  for (const m of await $.session.messages()) {
17    for (const u of m.toolUses) if (isBackground(u.tool, u.input) && !u.isError) started.push(u.tool_use_id)
18    // 終了したバックグラウンド作業は <task-notification> の <tool-use-id> で届く
19    if (m.role === 'user') for (const x of m.text.matchAll(/<tool-use-id>([^<]+)<\/tool-use-id>/g)) ended.add(x[1]!)
20  }
21  const agents = (await $.agent.list()).filter(a => !a.parentId)
22  const agentsRunning = agents.filter(a => a.status === 'running').length
23  const shellsRunning = started.filter(id => !ended.has(id)).length
24  await update($, jobs, () => ({
25    running: shellsRunning + agentsRunning,
26    done: started.length - shellsRunning + agents.length - agentsRunning,
27  }))
28}
29
30const setLimits = ($: EngineInterface, rl: SessionRateLimit[]) =>
31  update($, limits, () => Object.fromEntries(rl.map(l => [l.kind, { percentUsed: l.percentUsed, resetsAt: l.resetsAt }])))
32
33const pad = (n: number) => String(n).padStart(2, '0')
34const resetLabel = (iso: string | undefined, withDate: boolean) => {
35  if (!iso) return ''
36  const d = new Date(iso)
37  const time = `${d.getHours()}:${pad(d.getMinutes())}`
38  return ` ↻${withDate ? `${d.getMonth() + 1}/${d.getDate()} ` : ''}${time}`
39}
40const colorOf = (p: number) => (p >= 90 ? 'red' : p >= 70 ? 'yellow' : undefined)
41
42// ゲージは前回値から今回値へ伸び縮みさせる。同じ値の再描画では source を変えず、アニメーションを再生し直さない
43const anim = new Map<string, { from: number; to: number }>()
44const sweep = (kind: string, to: number) => {
45  const a = anim.get(kind)
46  if (a?.to !== to) anim.set(kind, { from: a?.to ?? 0, to })
47  return anim.get(kind)!
48}
49
50const BAR = 132
51const GAUGE = 156
52const fill = (p: number) => (p >= 90 ? 'url(#hot)' : p >= 70 ? 'url(#warm)' : 'url(#cool)')
53
54function gauge(x: number, label: string, kind: string, w: Limit, withDate: boolean) {
55  const p = Math.min(100, w.percentUsed)
56  const { from, to } = sweep(kind, p)
57  const px = (v: number) => Math.max(6, (BAR * v) / 100).toFixed(1)
58  const pulse = p >= 90 ? '<animate attributeName="opacity" values="1;.45;1" dur="1.6s" repeatCount="indefinite"/>' : ''
59  return `<g transform="translate(${x},0)">
60  <text x="0" y="17" class="tiny">${label}</text>
61  <text x="20" y="17" class="big">${Math.round(p)}<tspan class="unit">%</tspan></text>
62  <text x="${BAR}" y="17" class="dim" text-anchor="end">${resetLabel(w.resetsAt, withDate).trim()}</text>
63  <rect x="0" y="26" width="${BAR}" height="7" rx="3.5" class="bar"/>
64  <rect x="0" y="26" width="${px(to)}" height="7" rx="3.5" fill="${fill(p)}">
65    <animate attributeName="width" from="${px(from)}" to="${px(to)}" dur="1.1s" calcMode="spline" keySplines=".22 1 .36 1" keyTimes="0;1" fill="freeze"/>${pulse}
66  </rect>
67</g>`
68}
69
70function jobsChip(x: number, j: Jobs) {
71  const live = j.running > 0
72  const icon = live
73    ? `<circle cx="14" cy="22" r="8" class="track" stroke-width="2.5"/>
74  <circle cx="14" cy="22" r="8" fill="none" stroke="url(#cool)" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="14 50">
75    <animateTransform attributeName="transform" type="rotate" from="0 14 22" to="360 14 22" dur=".9s" repeatCount="indefinite"/>
76  </circle>`
77    : `<path d="M8 22.5l4 4 8-9" fill="none" stroke="url(#cool)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
78    stroke-dasharray="20" stroke-dashoffset="20"><animate attributeName="stroke-dashoffset" to="0" dur=".5s" fill="freeze"/></path>`
79  return `<g transform="translate(${x},0)">
80  <rect x="0" y="6" width="150" height="32" rx="16" class="chip"/>
81  ${icon}
82  <text x="30" y="20" class="big small">${j.running}<tspan class="unit"> 実行中</tspan></text>
83  <text x="30" y="33" class="dim">✓ ${j.done} 完了</text>
84</g>`
85}
86
87function bandSvg(shown: [string, string, Limit, boolean][], j: Jobs) {
88  const parts = shown.map(([label, kind, w, withDate], i) => gauge(i * GAUGE, label, kind, w, withDate))
89  const width = shown.length * GAUGE + (j.running + j.done > 0 ? 154 : 0)
90  if (j.running + j.done > 0) parts.push(jobsChip(shown.length * GAUGE, j))
91  return {
92    width,
93    source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="44" viewBox="0 0 ${width} 44">
94<style>
95  text { font-family: -apple-system, system-ui, sans-serif; fill: #1f1e1d }
96  .big { font-size: 15px; font-weight: 650; font-variant-numeric: tabular-nums }
97  .small { font-size: 13px }
98  .unit { font-size: 10px; font-weight: 500; opacity: .6 }
99  .dim { font-size: 10px; opacity: .55 }
100  .tiny { font-size: 11px; font-weight: 600; opacity: .6 }
101  .track { fill: none; stroke: #00000014; stroke-width: 4 }
102  .bar { fill: #00000012 }
103  .chip { fill: #0000000a; stroke: #00000012 }
104  @media (prefers-color-scheme: dark) {
105    text { fill: #f5f4ef } .bar { fill: #ffffff1a } .track { stroke: #ffffff1c } .chip { fill: #ffffff0d; stroke: #ffffff17 }
106  }
107</style>
108<defs>
109  <linearGradient id="cool" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5eb3ff"/><stop offset="1" stop-color="#8b6cff"/></linearGradient>
110  <linearGradient id="warm" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ffc24b"/><stop offset="1" stop-color="#ff8a3d"/></linearGradient>
111  <linearGradient id="hot" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ff6b5e"/><stop offset="1" stop-color="#e5307a"/></linearGradient>
112</defs>
113${parts.join('\n')}
114</svg>`,
115  }
116}
117
118export const register: Register = on => {
119  on('session.start', async ($, e, next) => {
120    await setLimits($, (await $.session.usage()).rateLimits)
121    await refreshJobs($)
122    return next(e)
123  })
124
125  on('session.measure', async ($, e, next) => {
126    if (e.changed.includes('rateLimits')) await setLimits($, e.rateLimits)
127    return next(e)
128  })
129
130  on('tool.call', async ($, e, next) => {
131    const done = await next(e)
132    if (e.tool === 'Agent' || e.tool === 'TaskStop' || isBackground(e.tool, e)) await refreshJobs($)
133    return done
134  })
135
136  on('turn.complete', async ($, e, next) => {
137    const done = await next(e)
138    await refreshJobs($)
139    return done
140  })
141
142  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143    if (e.props.hasSurvey) return next(e)
144    const l = await read($, limits)
145    const j = await read($, jobs)
146    const windows: [string, Limit | undefined, boolean][] = [['5h', l.five_hour, false], ['1w', l.seven_day, true]]
147    const shown = windows.filter((w): w is [string, Limit, boolean] => !!w[1])
148    if (shown.length === 0 && j.running + j.done === 0) return next(e)
149
150    const els = $.ui.resolve(e)
151    // Svg を持つ面(Desktop)ではアニメーション付きの帯、持たない面(ターミナル)では文字の帯
152    if ('Svg' in els) {
153      const { Svg } = els
154      const kinds: Record<string, string> = { '5h': 'five_hour', '1w': 'seven_day' }
155      const { width, source } = bandSvg(shown.map(([label, w, d]) => [label, kinds[label]!, w, d]), j)
156      const alt = [...shown.map(([label, w]) => `${label} ${Math.round(w.percentUsed)}%`), `${j.running} 実行中 ${j.done} 完了`].join(', ')
157      return <Svg source={source} alt={alt} width={width} height={44} isInteractive />
158    }
159
160    const { Box, Text } = els
161
162    return (
163      <Box flexDirection="row" gap={2}>
164        {shown.map(([label, w, withDate]) => (
165          <Text key={label} color={colorOf(w.percentUsed)} dimColor={!colorOf(w.percentUsed)}>
166            {label} {Math.round(w.percentUsed)}%{resetLabel(w.resetsAt, withDate)}
167          </Text>
168        ))}
169        {j.running + j.done > 0 && (
170          <Text dimColor={j.running === 0} color={j.running > 0 ? 'cyan' : undefined}>
171            ▶ {j.running} 実行中 · ✓ {j.done} 完了
172          </Text>
173        )}
174      </Box>
175    )
176  })
177}
178
types/index.d.ts 9 lines
1export type Limit = { percentUsed: number; resetsAt?: string }
2export type Jobs = { running: number; done: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'usage-band': { limits: Record<string, Limit>; jobs: Jobs }
7  }
8}
9