Skill: takao-mochizuki/openclaw-usage-manager (version 1.1.0)

--- name: openclaw-usage-manager description: Real-time usage dashboard and auto-switcher for dual Claude Max accounts. Use when: (1) checking C1/C2 utilization, (2) switching between accounts, (3) monitoring 5h/7d rate limits. Launches a browser dashboard showing both accounts side-by-side. Auto-switches when either 5h or 7d utilization exceeds 80%. --- # OpenClaw Usage Manager Monitor and auto-switch between two Claude Max accounts (C1/C2) based on 5-hour and 7-day utilization rates. ## What This Does - **Usage Dashboard**: Browser UI showing C1/C2 utilization in real time - **Auto-Switcher**: Checks rates every 3h (via cron), switches when either 5h OR 7d hits 80% - **Security-audited**: Reviewed by Claude Code + Codex before release ## Setup See full setup guide: https://github.com/Takao-Mochizuki/openclaw-usage-manager ### Quick Start ```bash # 1. Clone and install git clone https://github.com/Takao-Mochizuki/openclaw-usage-manager.git mkdir -p ~/.openclaw/workspace/tools/usage-dashboard mkdir -p ~/.openclaw/workspace/tools/usage-switch cp usage-dashboard/server.mjs ~/.openclaw/workspace/tools/usage-dashboard/ cp usage-switch/check.mjs ~/.openclaw/workspace/tools/usage-switch/ cp usage-switch/setup-tokens.sh ~/.openclaw/workspace/tools/usage-switch/ # 2. Configure 1Password item IDs in setup-tokens.sh and server.mjs # Replace "your-c1-item-id" and "your-c2-item-id" with your actual 1Password item IDs # 3. Run one-time token setup (requires TouchID) chmod +x ~/.openclaw/workspace/tools/usage-switch/setup-tokens.sh ~/.openclaw/workspace/tools/usage-switch/setup-tokens.sh # 4. Add usage alias to ~/.zshrc alias usage='lsof -ti:18800 | xargs kill -9 2>/dev/null; sleep 0.5; node ~/.openclaw/workspace/tools/usage-dashboard/server.mjs & sleep 1 && open http://localhost:18800' ``` ## Usage ```bash # Launch dashboard usage # Check utilization (JSON output) node ~/.openclaw/workspace/tools/usage-switch/check.mjs # Output example: # {"c1":{"5h":7,"7d":43,"over":false},"c2":{"5h":4,"7d":69,"over":false},"current":"C2","needSwitch":false} ``` ## OpenClaw Cron (Auto-Switch every 3h) Set up a cron job in OpenClaw with schedule `0 */3 * * *`: ``` Run: node ~/.openclaw/workspace/tools/usage-switch/check.mjs If needSwitch is true → run openclaw gateway restart and notify your channel. If bothOver is true → post manual intervention request. Otherwise → silent. ``` ## Requirements - OpenClaw installed - Claude Max × 2 accounts - Node.js >= 18 - 1Password CLI (`op`) recommended for secure token storage ## Security - tokens.json: chmod 600, excluded from git - Atomic writes: tmp file + renameSync - Dashboard: localhost-only, CSRF tokens, POST-only API - Audited by Claude Code + Codex ## Links - GitHub: https://github.com/Takao-Mochizuki/openclaw-usage-manager - Author: @5dmgmt (五次元経営株式会社)

path: README.md
content: # openclaw-usage-manager

> Real-time usage dashboard & auto-switcher for dual Claude Max accounts on OpenClaw.
>
> Claude Max（Anthropic）の2アカウント（C1/C2）の使用量をリアルタイム監視し、80%超えで自動切り替えするツール。OpenClawユーザー向け。

---

## Background / 背景

**The problem:**
If you run Claude Max on two accounts, there's no built-in way to see both accounts' remaining capacity at once. You have to log into each account separately on claude.ai to check.

**問題:**
Claude Maxを2アカウントで運用していると、片方のアカウントでclaude.aiを開いても、もう片方の残量がわからない。確認するには毎回ログインし直す必要がある。

This tool was built to solve that. See the original post:
https://x.com/5dmgmt/status/2032770037728113118

---

## Table of Contents / 目次

1. [Overview / 概要](#overview--概要)
2. [Prerequisites / 前提条件](#prerequisites--前提条件)
3. [What is C1/C2? / C1/C2とは](#what-is-c1c2--c1c2とは)
4. [Getting Your Tokens / トークン取得方法](#getting-your-tokens--トークン取得方法)
5. [Installation / インストール](#installation--インストール)
6. [Usage: Dashboard / ダッシュボードの使い方](#usage-dashboard--ダッシュボードの使い方)
7. [Usage: Auto-Switcher / 自動切り替えの設定](#usage-auto-switcher--自動切り替えの設定)
8. [Verification / 動作確認](#verification--動作確認)
9. [Manual Switch / 強制切り替え](#manual-switch--強制切り替え)
10. [Security / セキュリティ](#security--セキュリティ)
11. [How It Works / 仕組み](#how-it-works--仕組み)
12. [License & Author / ライセンス・作者](#license--author--ライセンス作者)

---

## Overview / 概要

This repository provides two tools for managing dual Claude Max accounts through OpenClaw:

このリポジトリは、OpenClaw経由でClaude Max 2アカウントを管理するための2つのツールを提供します。

### 1. Usage Dashboard（使用量ダッシュボード）

A browser-based real-time dashboard showing both accounts side-by-side.

ブラウザでC1/C2両アカウントの使用量をリアルタイム表示するダッシュボード。

- **Session remaining** — 5-hour utilization with countdown / 5時間セッション残量（カウントダウン付き）
- **Weekly utilization** — 7-day usage for all models / 全モデル週間使用率
- **Sonnet-only usage** — Sonnet model weekly utilization / Sonnetモデル単体の週間使用率
- **Reset countdown** — When each window resets / リセット日時のカウントダウン
- Color-coded bars: green (<60%), amber (60–80%), red (>=80%)

### 2. Auto-Switcher（自動切り替え）

A CLI script that checks usage and switches to the other account when either the 5-hour or 7-day utilization exceeds 80%.

5時間 or 週間使用率が80%を超えたら、もう一方のアカウントに自動で切り替えるCLIスクリプト。

- Designed to run as an OpenClaw cron job every 3 hours / OpenClaw cronで3時間ごとに実行
- Security-audited (Claude Code + Codex) / セキュリティ監査済み

```
C1: {5h: 7%, 7d: 43%}
C2: {5h: 4%, 7d: 69%} ← switches to C1 when C2 crosses 80%
```

### File structure / ファイル構成

```
openclaw-usage-manager/
├── usage-dashboard/
│   ├── server.mjs        # Dashboard server (Node.js, zero dependencies)
│   └── index.html         # Dashboard UI (dark theme, responsive)
├── usage-switch/
│   ├── check.mjs               # Usage checker & auto-switcher
│   ├── setup-tokens.sh         # One-time setup (1Password → tokens.json)
│   └── setup-tokens-simple.sh  # One-time setup (interactive, no 1Password)
├── .gitignore             # Excludes tokens.json, *.env
└── README.md
```

---

## Prerequisites / 前提条件

| Requirement | Notes |
|---|---|
| [OpenClaw](https://openclaw.ai) | Installed and configured / インストール・設定済み |
| Claude Max x 2 accounts | Two separate Anthropic subscriptions (C1 and C2) / 2つのAnthropicサブスクリプション |
| Node.js >= 18 | Required for `fetch()` API / `fetch()` APIのため |
| [1Password CLI](https://developer.1password.com/docs/cli/) (`op`) | **Optional** — `op` command recommended, not required / セキュアなトークン管理（推奨だが必須ではない） |

> **Why Node.js only?** This project has zero npm dependencies. It uses only Node.js built-in modules (`http`, `fs`, `crypto`). No `npm install` required.
>
> **なぜNode.jsだけ？** npm依存パッケージはゼロ。Node.js組み込みモジュールのみ使用。`npm install` は不要です。

---

## What is C1/C2? / C1/C2とは

### Why two accounts? / なぜ2アカウントが必要か

Claude Max has usage limits that reset on a rolling schedule: a **5-hour session window** and a **7-day weekly window**. When either limit is hit, you're rate-limited until the window resets.

Claude Maxには、**5時間セッション枠**と**7日間の週間枠**の使用制限があります。どちらかに達するとリセットまでレート制限されます。

By subscribing with **two separate accounts** (C1 and C2), you can switch to the other account when one is throttled — effectively doubling your available capacity.

**2つのアカウント**（C1とC2）を持つことで、片方が制限に達したらもう片方に切り替えられます。実質的に利用可能な容量が倍になります。

```
Account C1: resets on Friday   → heavy use Mon–Thu
Account C2: resets on Tuesday  → heavy use Fri–Mon

When C1 is at 85%, switch to C2 (still at 30%).
C1が85%に達したら、まだ30%のC2に切り替え。
```

> **Tip:** This approach is most effective when the two accounts have **different reset days** (e.g., C1 resets on Friday, C2 resets on Tuesday). If both reset on the same day, the benefit is reduced.
>
> **ヒント:** 2つのアカウントの**リセット日が異なる**場合に最も効果的です（例: C1=金曜リセット、C2=火曜リセット）。同じ日にリセットされると効果は薄くなります。

---

## Getting Your Tokens / トークン取得方法

You need an Anthropic API token (`sk-ant-...`) for each account. There are two ways to manage them.

各アカウントのAnthropic APIトークン（`sk-ant-...`）が必要です。管理方法は2通りあります。

### Option A: 1Password CLI (Recommended / 推奨)

**Why:** Tokens are never stored in plaintext on disk. 1Password handles encryption and access control. The dashboard fetches tokens from 1Password on each request.

**理由:** トークンがディスク上に平文で保存されません。1Passwordが暗号化とアクセス制御を行います。ダッシュボードはリクエストごとに1Passwordからトークンを取得します。

#### Step 1: Install 1Password CLI / 1Password CLIのインストール

```bash
# macOS (Homebrew)
brew install --cask 1password-cli

# Verify installation
op --version
```

#### Step 2: Save your tokens in 1Password / トークンを1Passwordに保存

Create two items in your 1Password vault — one for each account. The token field value must start with `sk-ant`.

1Passwordのボールトに2つのアイテムを作成します。トークンフィールドの値は `sk-ant` で始まる必要があります。

```bash
# Example: create items named "Anthropic C1" and "Anthropic C2"
# The important thing is that the field value starts with "sk-ant"
```

#### Step 3: Find your 1Password item IDs / アイテムIDを確認

```bash
# List items containing "anthropic"
op item list | grep -i anthropic

# Get the item ID (26-character string)
op item get "Anthropic C1" --format=json | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])"
# Output example: abcdefghijklmnopqrstuvwxyz
```

#### Step 4: Run setup script / セットアップスクリプト実行

The setup script reads tokens from 1Password and saves them to `tokens.json` (used by the auto-switcher).

セットアップスクリプトは1Passwordからトークンを読み取り、`tokens.json`に保存します（自動切り替え用）。

```bash
# Edit the item IDs in setup-tokens.sh first (see Installation step 3)
~/.openclaw/workspace/tools/usage-switch/setup-tokens.sh

# You'll be prompted for TouchID / biometric authentication
# TouchID／生体認証を求められます
```

Expected output:
```
🔑 1Passwordからトークンを取得中...
✅ tokens.json に保存完了
✅ セットアップ完了
```

### Option B: Direct token entry (Simple / シンプル)

**Why:** Quick setup without 1Password. Suitable for single-user machines with full-disk encryption.

**理由:** 1Passwordなしで素早くセットアップ。フルディスク暗号化されたシングルユーザーマシンに適しています。

> **Warning:** This method stores tokens in plaintext. Make sure the file is not committed to git and has restrictive permissions.
>
> **注意:** この方法ではトークンが平文で保存されます。gitにコミットされないこと、パーミッションが制限されていることを確認してください。

#### Step 1: Get your tokens / トークンの取得

Use the `claude setup-token` command in your terminal. This is a built-in Claude CLI command that guides you through the OAuth flow and outputs your API token.

ターミナルで `claude setup-token` コマンドを実行します。これはClaude CLI組み込みのコマンドで、OAuthフローを案内し、APIトークンを出力します。

```bash
# For account C1 — log in to your first Anthropic account
claude setup-token
# 1. A browser window opens with the Anthropic OAuth page
# 2. Log in with your C1 account credentials
# 3. Authorize the application
# 4. The terminal displays your token (starts with sk-ant-...)
# 5. Copy this token — you'll need it in Step 2

# For account C2 — log in to your second Anthropic account
# Use a different browser profile or incognito window to avoid session conflicts
claude setup-token
# Same flow as above, but log in with your C2 credentials
# Copy the second token
```

#### Step 2: Create tokens.json manually / tokens.jsonを手動作成

```bash
# Create the directory if it doesn't exist
mkdir -p ~/.openclaw/workspace/tools/usage-switch

# Write tokens.json with your actual tokens
cat > ~/.openclaw/workspace/tools/usage-switch/tokens.json << 'EOF'
{
  "c1": "[REDACTED_SECRET]",
  "c2": "[REDACTED_SECRET]"
}
EOF

# Set restrictive permissions (owner read/write only)
# Why: prevents other users on the system from reading your API tokens
# パーミッションを制限（所有者のみ読み書き可）— 他のユーザーからの読み取りを防止
chmod 600 ~/.openclaw/workspace/tools/usage-switch/tokens.json
```

> **Note for dashboard:** If using Option B, you'll need to modify `server.mjs` to read tokens from `tokens.json` instead of 1Password. The default dashboard uses 1Password CLI directly.
>
> **ダッシュボードの注意:** Option Bの場合、`server.mjs`を修正して`tokens.json`からトークンを読む必要があります。デフォルトのダッシュボードは1Password CLIを直接使用します。

---

## Installation / インストール

### Step 1: Clone the repository / リポジトリをクローン

```bash
git clone https://github.com/Takao-Mochizuki/openclaw-usage-manager.git
cd openclaw-usage-manager
```

### Step 2: Copy files to OpenClaw workspace / ファイルを配置

**Why this path?** OpenClaw expects user tools under `~/.openclaw/workspace/tools/`. This makes them accessible to OpenClaw agents and cron jobs.

**なぜこのパス？** OpenClawはユーザーツールを `~/.openclaw/workspace/tools/` 配下に配置する規約です。これによりOpenClawのエージェントやcronからアクセス可能になります。

```bash
# Create directories
mkdir -p ~/.openclaw/workspace/tools/usage-dashboard
mkdir -p ~/.openclaw/workspace/tools/usage-switch

# Copy all files
cp usage-dashboard/server.mjs ~/.openclaw/workspace/tools/usage-dashboard/
cp usage-dashboard/index.html ~/.openclaw/workspace/tools/usage-dashboard/
cp usage-switch/check.mjs ~/.openclaw/workspace/tools/usage-switch/
cp usage-switch/setup-tokens.sh ~/.openclaw/workspace/tools/usage-switch/
cp usage-switch/setup-tokens-simple.sh ~/.openclaw/workspace/tools/usage-switch/

# Make setup scripts executable
chmod +x ~/.openclaw/workspace/tools/usage-switch/setup-tokens.sh
chmod +x ~/.openclaw/workspace/tools/usage-switch/setup-tokens-simple.sh
```

### Step 3: Configure tokens / トークンを設定

#### 方法A: 対話式入力（推奨・1Password不要）
#### Method A: Interactive input (recommended, no 1Password needed)

トークンは `claude setup-token` コマンドで取得できます。
Run `claude setup-token` to get your token.

```bash
chmod +x ~/.openclaw/workspace/tools/usage-switch/setup-tokens-simple.sh
~/.openclaw/workspace/tools/usage-switch/setup-tokens-simple.sh
# プロンプトに従いC1・C2のトークンを入力
# Follow the prompts to enter your C1 and C2 tokens
```

#### 方法B: 1Password CLI（セキュリティ重視）
#### Method B: 1Password CLI (for security-conscious users)

Replace the hardcoded item IDs in these files:

以下のファイルのアイテムIDを自分のものに書き換えます。

**`setup-tokens.sh`** — lines 5–6:
```bash
# Find and replace the item IDs:
C1_TOKEN=$(op item get your-c1-item-id ...)
C2_TOKEN=$(op item get your-c2-item-id ...)
```

**`server.mjs`** — the `ACCOUNTS` object near the top:
```javascript
const ACCOUNTS = {
  c1: { label: "C1", opItemId: "your-c1-item-id" },
  c2: { label: "C2", opItemId: "your-c2-item-id" },
};
```

Then run the 1Password setup script:

```bash
~/.openclaw/workspace/tools/usage-switch/setup-tokens.sh
```

### Step 4: Update file paths (if needed) / パスの確認

The auto-switcher (`check.mjs`) reads and writes OpenClaw's `auth-profiles.json`. The default paths are:

自動切り替え（`check.mjs`）はOpenClawの`auth-profiles.json`を読み書きします。デフォルトのパス:

```javascript
// check.mjs — update these if your OpenClaw installation differs
const AUTH_FILE   = '/Users/<you>/.openclaw/agents/main/agent/auth-profiles.json';
const TOKENS_FILE = '/Users/<you>/.openclaw/workspace/tools/usage-switch/tokens.json';
```

**Why:** These are absolute paths because the script runs as a cron job, where `~` may not expand correctly.

**理由:** cronジョブとして実行されるため、`~` が正しく展開されない可能性があり、絶対パスを使用しています。

Find your actual path:
```bash
find ~/.openclaw -name "auth-profiles.json" 2>/dev/null
```

---

## Usage: Dashboard / ダッシュボードの使い方

### Quick launch / 起動

```bash
node ~/.openclaw/workspace/tools/usage-dashboard/server.mjs
# Output: Usage Dashboard: http://localhost:18800
```

Then open http://localhost:18800 in your browser.

ブラウザで http://localhost:18800 を開きます。

### Set up the `usage` alias / `usage` エイリアスの設定

**Why an alias?** It kills any existing dashboard process, starts a fresh server, and opens the browser — all in one command.

**なぜエイリアス？** 既存のダッシュボードプロセスを停止し、新しいサーバーを起動し、ブラウザを開く — これを1コマンドで実行します。

Add to `~/.zshrc`:

```bash
alias usage='lsof -ti:18800 | xargs kill -9 2>/dev/null; sleep 0.5; node ~/.openclaw/workspace/tools/usage-dashboard/server.mjs & sleep 1 && open http://localhost:18800'
```

Then reload your shell:

```bash
source ~/.zshrc
```

Now just type `usage` to launch the dashboard.

これで `usage` と入力するだけでダッシュボードが起動します。

### Dashboard features / ダッシュボードの機能

| Feature | Details |
|---|---|
| **Auto-refresh** | Polls every 60 seconds (toggle via checkbox) / 60秒ごとに自動更新（チェックボックスで切り替え） |
| **Manual refresh** | Click the Refresh button / Refreshボタンで手動更新 |
| **30-second cache** | Prevents excessive API calls / 30秒キャッシュでAPI呼び出しを抑制 |
| **Localhost only** | Binds to `127.0.0.1:18800` — not accessible from the network / ネットワークからアクセス不可 |
| **Dark theme** | Responsive grid layout, color-coded progress bars / レスポンシブグリッド、色分けプログレスバー |

---

## Usage: Auto-Switcher / 自動切り替えの設定

### Manual run / 手動実行

```bash
node ~/.openclaw/workspace/tools/usage-switch/check.mjs
```

Example output:
```json
{
  "c1": {"5h": 7, "7d": 43, "over": false},
  "c2": {"5h": 4, "7d": 69, "over": false},
  "current": "C2",
  "needSwitch": false,
  "switched": null,
  "bothOver": false
}
```

### Output fields / 出力フィールド

| Field | Description |
|---|---|
| `c1.5h` / `c2.5h` | 5-hour session utilization (%) / 5時間セッション使用率 |
| `c1.7d` / `c2.7d` | 7-day weekly utilization (%) / 7日間週間使用率 |
| `over` | `true` if 5h OR 7d >= 80% / 5hまたは7dが80%以上で`true` |
| `current` | Currently active account / 現在のアクティブアカウント |
| `needSwitch` | `true` if a switch was needed and performed / 切り替えが必要で実行された場合`true` |
| `switched` | e.g., `"C2→C1"` if switched, `null` otherwise / 切り替え実行時の遷移 |
| `bothOver` | `true` if both accounts >= 80% — requires manual intervention / 両方80%超えで手動対応が必要 |

### OpenClaw cron setup / OpenClaw cronの設定

**Why every 3 hours?** The 5-hour window means checking every 3 hours catches approaching limits before they're hit. More frequent checks would increase API calls unnecessarily.

**なぜ3時間ごと？** 5時間枠があるため、3時間ごとのチェックで制限到達前に検知できます。より頻繁なチェックはAPI呼び出しを不必要に増やします。

Configure a cron job in OpenClaw:

OpenClawのcronに以下を設定します。

```
Schedule: 0 */3 * * * (Asia/Tokyo)
```

Prompt for the cron job / cronジョブのプロンプト:

```
Run: node ~/.openclaw/workspace/tools/usage-switch/check.mjs

Parse the JSON output:
- If needSwitch is true → run `openclaw gateway restart` and notify #your-channel
- If bothOver is true → post a manual intervention request to #your-channel
- Otherwise → do nothing (silent)
```

---

## Verification / 動作確認

After installation, verify everything works:

インストール後、以下で動作を確認します。

### 1. Check usage / 使用率確認

```bash
node ~/.openclaw/workspace/tools/usage-switch/check.mjs
```

**Expected:** JSON output with `c1` and `c2` objects showing percentages.

**期待結果:** `c1`と`c2`の使用率を含むJSON出力。

**If you see an error / エラーが出た場合:**

```json
{"error":"tokens.json not found. Run setup-tokens.sh first."}
```

Re-run the setup script. See [Getting Your Tokens](#getting-your-tokens--トークン取得方法).

セットアップスクリプトを再実行してください。[トークン取得方法](#getting-your-tokens--トークン取得方法)を参照。

### 2. Launch dashboard / ダッシュボード起動

```bash
node ~/.openclaw/workspace/tools/usage-dashboard/server.mjs
```

**Expected output:**
```
Usage Dashboard: http://localhost:18800
```

Open http://localhost:18800 — you should see two cards (C1 and C2) with progress bars showing session and weekly utilization.

http://localhost:18800 を開くと、C1とC2の2枚のカードにプログレスバーが表示されます。

### 3. Verify 1Password access (if using Option A) / 1Passwordアクセス確認

```bash
op item get your-c1-item-id --reveal --format=json | python3 -c "
import json, sys
d = json.load(sys.stdin)
f = next((x for x in d.get('fields', []) if str(x.get('value', '')).startswith('sk-ant')), None)
print('OK: token found' if f else 'ERROR: no sk-ant field found')
"
```

**Expected:** `OK: token found`

---

## Manual Switch / 強制切り替え

For testing or emergencies, you can force-switch accounts without waiting for the auto-switcher.

テストや緊急時に、自動切り替えを待たずに手動でアカウントを切り替えられます。

### Switch to C1 / C1に切り替え

```bash
node -e "
const fs = require('fs');
const AUTH = process.env.HOME + '/.openclaw/agents/main/agent/auth-profiles.json';
const TOKENS = process.env.HOME + '/.openclaw/workspace/tools/usage-switch/tokens.json';
const auth = JSON.parse(fs.readFileSync(AUTH));
const tokens = JSON.parse(fs.readFileSync(TOKENS));
auth.profiles['anthropic:default'].[REDACTED_SECRET];
fs.writeFileSync(AUTH, JSON.stringify(auth, null, 2));
console.log('Switched to C1');
"
```

### Switch to C2 / C2に切り替え

```bash
node -e "
const fs = require('fs');
const AUTH = process.env.HOME + '/.openclaw/agents/main/agent/auth-profiles.json';
const TOKENS = process.env.HOME + '/.openclaw/workspace/tools/usage-switch/tokens.json';
const auth = JSON.parse(fs.readFileSync(AUTH));
const tokens = JSON.parse(fs.readFileSync(TOKENS));
auth.profiles['anthropic:default'].[REDACTED_SECRET];
fs.writeFileSync(AUTH, JSON.stringify(auth, null, 2));
console.log('Switched to C2');
"
```

### After switching — restart the gateway / 切り替え後 — gatewayの再起動

**Important:** Always restart the gateway after switching tokens. The running OpenClaw process caches the token in memory.

**重要:** トークン切り替え後は必ずgatewayを再起動してください。実行中のOpenClawプロセスはトークンをメモリにキャッシュしています。

```bash
openclaw gateway restart
```

---

## Security / セキュリティ

This project has been security-audited using Claude Code and Codex. Key measures:

このプロジェクトはClaude CodeとCodexによるセキュリティ監査済みです。主な対策:

### Token handling / トークンの取り扱い

| Measure | Details |
|---|---|
| **1Password integration** | Dashboard reads tokens from 1Password on each request — no plaintext storage for the dashboard / ダッシュボードは毎回1Passwordからトークンを取得、平文保存なし |
| **tokens.json permissions** | Created with `chmod 600` (owner read/write only) / 所有者のみ読み書き可で作成 |
| **git exclusion** | `tokens.json` and `*.env` are in `.gitignore` / `.gitignore`で除外済み |
| **Injection prevention** | `setup-tokens.sh` passes tokens via environment variables to Python, preventing shell injection / 環境変数経由でPythonに渡し、シェルインジェクションを防止 |

### Dashboard security / ダッシュボードのセキュリティ

| Measure | Details |
|---|---|
| **Localhost binding** | Server binds to `127.0.0.1` only — not accessible from the network / `127.0.0.1`のみバインド、ネットワークからアクセス不可 |
| **CSRF protection** | Random 32-byte hex token required in `x-dashboard-csrf` header / ランダム32バイトCSRFトークンをAPIリクエストに要求 |
| **Host validation** | Only accepts requests from `localhost:18800` or `127.0.0.1:18800` / ローカルホストからのリクエストのみ受付 |
| **Origin validation** | Checks `Origin` header to prevent cross-origin attacks / `Origin`ヘッダーを検証しクロスオリジン攻撃を防止 |
| **POST-only API** | Usage endpoint only accepts POST — prevents data leaks via GET / 使用量エンドポイントはPOSTのみ |
| **No innerHTML** | Dashboard UI builds DOM elements programmatically — no XSS risk / DOM要素をプログラム的に構築、XSSリスクなし |

### Auto-switcher security / 自動切り替えのセキュリティ

| Measure | Details |
|---|---|
| **Atomic writes** | Token switch uses temp file + `renameSync` to prevent corruption / 一時ファイル + リネームのアトミック操作で破損を防止 |
| **Minimal API payload** | Sends `max_tokens: 1` with content `"."` — minimal cost and exposure / 最小限のペイロードでコストと露出を低減 |
| **Request limits** | Server configures `headersTimeout`, `requestTimeout`, `keepAliveTimeout`, and `maxRequestsPerSocket` / タイムアウトと最大リクエスト数を設定 |

---

## How It Works / 仕組み

### The Anthropic rate-limit headers / Anthropicのレートリミットヘッダー

Both tools work by sending a **minimal API request** to the Anthropic Messages API and reading the **response headers**. No actual conversation is generated — the request uses `max_tokens: 1` with a single-character message.

両ツールとも、Anthropic Messages APIに**最小限のリクエスト**を送信し、**レスポンスヘッダー**を読み取ります。実際の会話は生成されません — `max_tokens: 1` で1文字のメッセージを送信します。

**Request sent:**
```http
POST https://api.anthropic.com/v1/messages
Authorization: Bearer sk-ant-...
anthropic-version: 2023-06-01
anthropic-beta: claude-code-20250219,oauth-2025-04-20
Content-Type: application/json

{"model":"claude-sonnet-4-20250514","max_tokens":1,"messages":[{"role":"user","content":"."}]}
```

**Key response headers:**

| Header | Description |
|---|---|
| `anthropic-ratelimit-unified-5h-utilization` | 5-hour session utilization (0.0–1.0+) / 5時間セッション使用率 |
| [REDACTED_SECRET] | Unix timestamp when 5h window resets / 5時間枠リセット時刻（Unixタイムスタンプ） |
| `anthropic-ratelimit-unified-7d-utilization` | 7-day weekly utilization (0.0–1.0+) / 7日間週間使用率 |
| [REDACTED_SECRET] | Unix timestamp when 7d window resets / 7日間枠リセット時刻 |
| [REDACTED_SECRET] | Sonnet-specific weekly utilization / Sonnet専用週間使用率 |
| `anthropic-ratelimit-unified-status` | `"allowed"` or `"rejected"` / 許可 or 拒否 |
| `anthropic-ratelimit-unified-overage-utilization` | Overage pool utilization / 超過プール使用率 |
| `anthropic-ratelimit-unified-fallback-percentage` | Fallback pool percentage / フォールバックプール割合 |

### Auto-switch logic / 自動切り替えロジック

```
1. Fetch usage for C1 and C2 in parallel
   C1とC2の使用率を並行取得

2. For each account: mark as "over" if 5h >= 80% OR 7d >= 80%
   5hまたは7dが80%以上なら「超過」とマーク

3. If current account is "over" AND alternate is NOT "over":
   → Switch token in auth-profiles.json (atomic write)
   現在のアカウントが超過 かつ 代替が未超過の場合
   → auth-profiles.jsonのトークンを切り替え（アトミック書き込み）

4. If BOTH accounts are "over":
   → Do NOT switch. Report bothOver: true for manual intervention.
   両方超過の場合 → 切り替えない。bothOver: trueを報告し手動対応を要求。
```

---

## License & Author / ライセンス・作者

**License:** MIT

**Author:** [@5dmgmt](https://x.com/5dmgmt) — 五次元経営株式会社 / 5th Dimension Management, Inc.

Website: [5dmgmt.com](https://5dmgmt.com)


path: usage-dashboard/index.html
content: <!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude Max Usage Dashboard</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
    background: #0f1117;
    color: #e4e4e7;
    min-height: 100vh;
    padding: 24px;
  }
  .header { text-align: center; margin-bottom: 32px; }
  .header h1 { font-size: 20px; font-weight: 600; color: #fff; margin-bottom: 4px; }
  .header .sub { font-size: 13px; color: #71717a; margin-bottom: 8px; }
  .header .updated { font-size: 13px; color: #71717a; }
  .header .updated .time { color: #a1a1aa; }
  .grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
    gap: 24px;
    max-width: 1200px;
    margin: 0 auto;
  }
  @media (max-width: 500px) { .grid { grid-template-columns: 1fr; } }
  .card {
    background: #18181b;
    border: 1px solid #27272a;
    border-radius: 12px;
    padding: 24px;
  }
  .card-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 20px;
  }
  .card-title { font-size: 18px; font-weight: 600; }
  .badge {
    font-size: 11px; padding: 3px 8px; border-radius: 9999px; font-weight: 500;
  }
  .badge-ok { background: #052e16; color: #4ade80; }
  .badge-warn { background: #422006; color: #fbbf24; }
  .badge-error { background: #450a0a; color: #f87171; }
  .section { margin-bottom: 18px; }
  .section:last-child { margin-bottom: 0; }
  .section-header {
    display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px;
  }
  .section-title { font-size: 14px; font-weight: 600; color: #d4d4d8; }
  .section-meta { font-size: 12px; color: #71717a; }
  .bar-bg {
    width: 100%; height: 8px; background: #27272a; border-radius: 4px; overflow: hidden;
  }
  .bar-fill {
    height: 100%; border-radius: 4px; transition: width 0.6s ease;
  }
  .bar-fill.warn { background: #f59e0b; }
  .bar-fill.danger { background: #ef4444; }
  .pct { font-size: 13px; font-weight: 500; color: #a1a1aa; margin-top: 4px; text-align: right; }
  .separator { border: none; border-top: 1px solid #27272a; margin: 16px 0; }
  .loading {
    text-align: center; padding: 60px 0; color: #71717a; font-size: 14px;
    grid-column: 1 / -1;
  }
  .spinner {
    display: inline-block; width: 20px; height: 20px;
    border: 2px solid #27272a; border-top-color: #60a5fa; border-radius: 50%;
    animation: spin 0.8s linear infinite; margin-right: 8px; vertical-align: middle;
  }
  @keyframes spin { to { transform: rotate(360deg); } }
  .controls { text-align: center; margin-bottom: 24px; }
  .btn {
    background: #27272a; color: #d4d4d8; border: 1px solid #3f3f46;
    padding: 6px 16px; border-radius: 6px; font-size: 13px; cursor: pointer;
    transition: background 0.2s;
  }
  .btn:hover { background: #3f3f46; }
  .btn:disabled { opacity: 0.5; cursor: not-allowed; }
  .auto-label { font-size: 12px; color: #71717a; margin-left: 12px; }
  .error-card {
    background: #1c1012; border-color: #450a0a; text-align: center; padding: 40px; color: #f87171;
  }
  .setup-hint {
    max-width: 600px; margin: 40px auto; text-align: center; color: #71717a;
    line-height: 1.8; font-size: 14px;
  }
</style>
</head>
<body>

<div class="header">
  <h1>Claude Max Usage Dashboard</h1>
  <div class="sub">Multiple account usage at a glance</div>
  <div class="updated">Last update: <span class="time" id="lastUpdated">--</span></div>
</div>

<div class="controls">
  <button class="btn" id="refreshBtn">Refresh</button>
  <label class="auto-label">
    <input type="checkbox" id="autoRefresh" checked> Auto-refresh every 60s
  </label>
</div>

<div class="grid" id="dashboard">
  <div class="loading"><span class="spinner"></span>Loading...</div>
</div>

<script>
const CSRF_TOKEN = '__CSRF_TOKEN__';
const ACCENT = ['#3b82f6','#8b5cf6','#f59e0b','#10b981','#ec4899','#06b6d4','#f97316','#84cc16','#e879f9'];
let autoTimer = null;

// --- DOM helpers (no innerHTML for dynamic data) ---
function el(tag, attrs, ...children) {
  const e = document.createElement(tag);
  if (attrs) {
    for (const [k, v] of Object.entries(attrs)) {
      if (k === 'text') { e.textContent = v; continue; }
      if (k === 'style' && typeof v === 'object') { Object.assign(e.style, v); continue; }
      if (k === 'className') { e.className = v; continue; }
      e.setAttribute(k, v);
    }
  }
  for (const c of children) {
    if (typeof c === 'string') e.appendChild(document.createTextNode(c));
    else if (c) e.appendChild(c);
  }
  return e;
}

function timeUntil(isoStr) {
  if (!isoStr) return '';
  const diff = new Date(isoStr) - Date.now();
  if (diff <= 0) return 'Resetting soon';
  const h = Math.floor(diff / 3600000);
  const m = Math.floor((diff % 3600000) / 60000);
  if (h > 24) { const d = Math.floor(h / 24); return `Resets in ${d}d ${h % 24}h`; }
  return `Resets in ${h}h ${m}m`;
}

function resetDay(isoStr) {
  if (!isoStr) return '';
  const d = new Date(isoStr);
  const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  return `Resets ${days[d.getDay()]} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
}

function barFillClass(pct) {
  if (pct >= 0.8) return 'bar-fill danger';
  if (pct >= 0.6) return 'bar-fill warn';
  return 'bar-fill';
}

function buildBadge(status) {
  const map = { allowed: ['OK', 'badge badge-ok'], rejected: ['Limited', 'badge badge-error'] };
  const [text, cls] = map[status] || ['?', 'badge badge-warn'];
  return el('span', { className: cls, text });
}

function buildBar(pct, color) {
  const cls = barFillClass(pct);
  const bg = (pct < 0.6) ? color : undefined;
  const fill = el('div', { className: cls, style: { width: `${Math.min(pct * 100, 100)}%`, ...(bg ? { background: bg } : {}) } });
  return el('div', { className: 'bar-bg' }, fill);
}

function buildSection(title, meta, pct, color) {
  const header = el('div', { className: 'section-header' },
    el('span', { className: 'section-title', text: title }),
    el('span', { className: 'section-meta', text: meta })
  );
  const bar = buildBar(pct, color);
  const pctText = el('div', { className: 'pct', text: `${Math.round(pct * 100)}% used` });
  return el('div', { className: 'section' }, header, bar, pctText);
}

function buildCard(data, idx) {
  const color = ACCENT[idx % ACCENT.length];

  if (data.error) {
    const card = el('div', { className: 'card error-card' },
      el('div', { className: 'card-title', text: data.label || '?' }),
      el('p', { style: { marginTop: '12px' }, text: data.error })
    );
    return card;
  }

  const s = data.session, w = data.weekly, sn = data.sonnet;

  const cardHeader = el('div', { className: 'card-header' },
    el('div', { className: 'card-title', style: { color }, text: data.label }),
    buildBadge(data.status)
  );

  const sessionSection = buildSection('Current Session', timeUntil(s.resetAt), s.utilization, color);
  const sep = el('hr', { className: 'separator' });
  const weeklySection = buildSection('Weekly (All Models)', resetDay(w.resetAt), w.utilization, color);
  const sonnetSection = buildSection('Sonnet Only', resetDay(sn.resetAt), sn.utilization, color);
  sonnetSection.style.marginTop = '14px';

  return el('div', { className: 'card' }, cardHeader, sessionSection, sep, weeklySection, sonnetSection);
}

async function refresh() {
  const btn = document.getElementById('refreshBtn');
  btn.disabled = true; btn.textContent = 'Loading...';
  const grid = document.getElementById('dashboard');
  try {
    const res = await fetch('/api/usage', {
      method: 'POST',
      headers: { 'x-dashboard-csrf': CSRF_TOKEN },
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();

    grid.textContent = ''; // clear safely

    if (data.error) {
      grid.appendChild(el('div', { className: 'setup-hint', text: data.error }));
      return;
    }
    const keys = Object.keys(data).sort();
    keys.forEach((k, i) => grid.appendChild(buildCard(data[k], i)));
    document.getElementById('lastUpdated').textContent = new Date().toLocaleTimeString();
  } catch (e) {
    grid.textContent = '';
    const msg = el('div', { className: 'loading', style: { color: '#f87171' }, text: `Error: ${e.message}` });
    grid.appendChild(msg);
  } finally {
    btn.disabled = false; btn.textContent = 'Refresh';
  }
}

function setupAutoRefresh() {
  if (autoTimer) clearInterval(autoTimer);
  if (document.getElementById('autoRefresh').checked) autoTimer = setInterval(refresh, 60000);
}
document.getElementById('refreshBtn').addEventListener('click', refresh);
document.getElementById('autoRefresh').addEventListener('change', setupAutoRefresh);
setupAutoRefresh();
refresh();
</script>
</body>
</html>


path: usage-dashboard/server.mjs
content: #!/usr/bin/env node
// OpenClaw C1/C2 Usage Dashboard
// Usage: node server.mjs
// Opens at http://localhost:18800

import { createServer } from "http";
import { randomBytes } from "crypto";
import { execSync } from "child_process";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = 18800;
const CSRF_TOKEN = randomBytes(32).toString("hex");
const CACHE_TTL_MS = 30_000;

let usageCache = null;
let cacheTimestamp = 0;
let inflightPromise = null;

const ACCOUNTS = {
  c1: { label: "C1", opItemId: "your-c1-item-id" },
  c2: { label: "C2", opItemId: "your-c2-item-id" },
};

function getTokenFrom1Password(itemId) {
  try {
    const raw = execSync(
      `op item get ${itemId} --reveal --format=json 2>/dev/null`,
      { encoding: "utf-8", timeout: 10000 }
    );
    const data = JSON.parse(raw);
    const field = data.fields?.find((f) => f.value?.startsWith("sk-ant"));
    return field?.value || null;
  } catch {
    return null;
  }
}

async function fetchUsage(token) {
  const res = await fetch("https://api.anthropic.com/v1/messages", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "anthropic-version": "2023-06-01",
      "anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
      "content-type": "application/json",
    },
    body: JSON.stringify({
      model: "claude-sonnet-4-20250514",
      max_tokens: 1,
      messages: [{ role: "user", content: "." }],
    }),
  });

  if (!res.ok) {
    return { error: true, status: res.status };
  }

  const headers = {};
  for (const [k, v] of res.headers.entries()) {
    if (k.startsWith("anthropic-ratelimit")) headers[k] = v;
  }
  return { error: false, headers };
}

function parseUsage(headers) {
  const get = (key) => headers[`anthropic-ratelimit-unified-${key}`];
  const ts = (v) => (v ? new Date(Number(v) * 1000).toISOString() : null);
  return {
    status: get("status"),
    session: {
      utilization: parseFloat(get("5h-utilization") || "0"),
      resetAt: ts(get("5h-reset")),
      status: get("5h-status"),
    },
    weekly: {
      utilization: parseFloat(get("7d-utilization") || "0"),
      resetAt: ts(get("7d-reset")),
      status: get("7d-status"),
    },
    sonnet: {
      utilization: parseFloat(get("7d_sonnet-utilization") || "0"),
      resetAt: ts(get("7d_sonnet-reset")),
      status: get("7d_sonnet-status"),
    },
    overage: {
      utilization: parseFloat(get("overage-utilization") || "0"),
      status: get("overage-status"),
      disabledReason: get("overage-disabled-reason") || null,
    },
    fallbackPct: parseFloat(get("fallback-percentage") || "0"),
  };
}

const LOOPBACK_HOSTS = new Set([`localhost:${PORT}`, `127.0.0.1:${PORT}`]);

function isAllowedHost(host) {
  return typeof host === "string" && LOOPBACK_HOSTS.has(host.toLowerCase());
}

function isAllowedOrigin(origin) {
  if (!origin) return true;
  try {
    const u = new URL(origin);
    return (u.protocol === "http:" || u.protocol === "https:")
      && LOOPBACK_HOSTS.has(u.host.toLowerCase());
  } catch {
    return false;
  }
}

function verifyRequest(req) {
  if (!isAllowedHost(req.headers["host"])) return false;
  if (req.headers["x-dashboard-csrf"] !== CSRF_TOKEN) return false;
  return isAllowedOrigin(req.headers["origin"]);
}

async function getUsageData() {
  if (usageCache && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
    return usageCache;
  }

  if (inflightPromise) return inflightPromise;

  inflightPromise = (async () => {
    const results = {};
    for (const [key, acc] of Object.entries(ACCOUNTS)) {
      const [REDACTED_SECRET]);
      if (!token) {
        results[key] = { error: "Token not found in 1Password", label: acc.label };
        continue;
      }
      const raw = await fetchUsage(token);
      if (raw.error) {
        results[key] = { error: `API error (status ${raw.status})`, label: acc.label };
      } else {
        results[key] = { ...parseUsage(raw.headers), label: acc.label };
      }
    }

    usageCache = results;
    cacheTimestamp = Date.now();
    return results;
  })().finally(() => { inflightPromise = null; });

  return inflightPromise;
}

const server = createServer(async (req, res) => {
  if (req.url === "/api/usage") {
    res.setHeader("Content-Type", "application/json");
    res.setHeader("Cache-Control", "no-store");

    if (req.method !== "POST") {
      res.statusCode = 405;
      res.end(JSON.stringify({ error: "Method not allowed" }));
      return;
    }
    if (!verifyRequest(req)) {
      res.statusCode = 403;
      res.end(JSON.stringify({ error: "Forbidden" }));
      return;
    }

    try {
      const data = await getUsageData();
      res.end(JSON.stringify(data));
    } catch (e) {
      res.statusCode = 500;
      res.end(JSON.stringify({ error: "Internal server error" }));
    }
    return;
  }

  if (req.url !== "/" && req.url !== "/index.html") {
    res.statusCode = 404;
    res.end("Not found");
    return;
  }
  res.setHeader("Content-Type", "text/html; charset=utf-8");
  const html = readFileSync(join(__dirname, "index.html"), "utf-8")
    .replace("__CSRF_TOKEN__", CSRF_TOKEN);
  res.end(html);
});

server.headersTimeout = 10_000;
server.requestTimeout = 15_000;
server.keepAliveTimeout = 5_000;
server.maxRequestsPerSocket = 100;

server.on("error", (err) => {
  if (err.code === "EADDRINUSE") {
    console.error(`Port ${PORT} is already in use. Killing existing process...`);
    try {
      execSync(`lsof -ti:${PORT} | xargs kill 2>/dev/null`);
    } catch {}
    setTimeout(() => {
      server.listen(PORT, "127.0.0.1");
    }, 500);
  } else {
    console.error(err);
    process.exit(1);
  }
});

server.listen(PORT, "127.0.0.1", () => {
  console.log(`Usage Dashboard: http://localhost:${PORT}`);
});


path: usage-switch/check.mjs
content: #!/usr/bin/env node
// C1/C2 Usage Checker & Auto-Switcher
// 5h使用率 OR 7d使用率 のどちらかが80%超えたら切り替え
// tokens.jsonからAPIキーを読む（1Password不要）
import { readFileSync, writeFileSync, renameSync } from 'fs';

const AUTH_FILE = `${process.env.HOME}/.openclaw/agents/main/agent/auth-profiles.json`;
const TOKENS_FILE = `${process.env.HOME}/.openclaw/workspace/tools/usage-switch/tokens.json`;
const THRESHOLD   = 0.80;

function loadTokens() {
  try {
    return JSON.parse(readFileSync(TOKENS_FILE, 'utf-8'));
  } catch {
    return null;
  }
}

async function getUsage(token) {
  try {
    const res = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'anthropic-version': '2023-06-01',
        'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20',
        'content-type': 'application/json',
      },
      body: JSON.stringify({model:'claude-sonnet-4-20250514', max_tokens:1, messages:[{role:'user',content:'.'}]}),
    });
    if (!res.ok) return null;
    const h5  = parseFloat(res.headers.get('anthropic-ratelimit-unified-5h-utilization')  || '0');
    const h7d = parseFloat(res.headers.get('anthropic-ratelimit-unified-7d-utilization')  || '0');
    return { h5, h7d, over: h5 >= THRESHOLD || h7d >= THRESHOLD };
  } catch { return null; }
}

async function main() {
  const tokens = loadTokens();
  if (!tokens?.c1 || !tokens?.c2) {
    console.log(JSON.stringify({error:'tokens.json not found. Run setup-tokens.sh first.'}));
    process.exit(1);
  }

  const [c1, c2] = await Promise.all([getUsage(tokens.c1), getUsage(tokens.c2)]);

  const c1_5h  = Math.round((c1?.h5  || 0) * 100);
  const c1_7d  = Math.round((c1?.h7d || 0) * 100);
  const c2_5h  = Math.round((c2?.h5  || 0) * 100);
  const c2_7d  = Math.round((c2?.h7d || 0) * 100);
  const c1Over = c1?.over ?? false;
  const c2Over = c2?.over ?? false;

  const auth = JSON.parse(readFileSync(AUTH_FILE, 'utf-8'));
  const current = auth.profiles['anthropic:default'].token;
  const usingC1     = current === tokens.c1;
  const currentName = usingC1 ? 'C1' : 'C2';
  const altToken    = usingC1 ? tokens.c2 : tokens.c1;
  const altName     = usingC1 ? 'C2' : 'C1';
  const currentOver = usingC1 ? c1Over : c2Over;
  const altOver     = usingC1 ? c2Over : c1Over;

  // 現在使用中が80%超え かつ 代替が80%未満 → 切り替え
  const needSwitch = currentOver && !altOver;

  if (needSwitch) {
    auth.profiles['anthropic:default'].[REDACTED_SECRET];
    const tmpFile = AUTH_FILE + ".tmp";
    writeFileSync(tmpFile, JSON.stringify(auth, null, 2));
    renameSync(tmpFile, AUTH_FILE); // アトミック操作
  }

  console.log(JSON.stringify({
    c1: { '5h': c1_5h, '7d': c1_7d, over: c1Over },
    c2: { '5h': c2_5h, '7d': c2_7d, over: c2Over },
    current: currentName,
    needSwitch,
    switched: needSwitch ? `${currentName}→${altName}` : null,
    bothOver: currentOver && altOver
  }));
}

main().catch(e => { console.log(JSON.stringify({error:String(e)})); process.exit(1); });


path: usage-switch/setup-tokens-simple.sh
content: #!/bin/bash
# シンプルセットアップ — 1Password不要
# トークンは claude setup-token コマンドで取得できます

TOKENS_FILE="$HOME/.openclaw/workspace/tools/usage-switch/tokens.json"

echo "🔑 C1アカウントのAPIトークンを入力してください（sk-ant-...）："
read -rs C1_TOKEN
echo ""
echo "🔑 C2アカウントのAPIトークンを入力してください（sk-ant-...）："
read -rs C2_TOKEN
echo ""

if [ -z "$C1_TOKEN" ] || [ -z "$C2_TOKEN" ]; then
  echo "❌ トークンが空です。キャンセルしました。"
  exit 1
fi

mkdir -p "$(dirname "$TOKENS_FILE")"
python3 -c "
import json, os
data = {'c1': os.environ['C1_TOKEN'], 'c2': os.environ['C2_TOKEN']}
with open('$TOKENS_FILE', 'w') as f:
    json.dump(data, f, indent=2)
print('✅ tokens.json に保存完了')
" C1_TOKEN="$C1_TOKEN" C2_TOKEN="$C2_TOKEN"

chmod 600 "$TOKENS_FILE"
echo "✅ セットアップ完了"
echo ""
echo "💡 トークンの取得方法: claude setup-token を実行してください"


path: usage-switch/setup-tokens.sh
content: #!/bin/bash
# C1/C2 トークンセットアップ（一度だけ実行）
echo "🔑 1Passwordからトークンを取得中..."

C1_TOKEN=$(op item get your-c1-item-id --reveal --format=json 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); f=next((x for x in d.get('fields',[]) if str(x.get('value','')).startswith('sk-ant')),None); print(f['value'] if f else '')")
C2_TOKEN=$(op item get your-c2-item-id --reveal --format=json 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); f=next((x for x in d.get('fields',[]) if str(x.get('value','')).startswith('sk-ant')),None); print(f['value'] if f else '')")

if [ -z "$C1_TOKEN" ] || [ -z "$C2_TOKEN" ]; then
  echo "❌ トークン取得失敗。1Passwordにログイン済みか確認してください"
  exit 1
fi

# 環境変数経由でPythonに渡す（シェル変数の直接展開によるインジェクション防止）
C1_TOKEN="$C1_TOKEN" C2_TOKEN="$C2_TOKEN" python3 -c "
import json, os
data = {'c1': os.environ['C1_TOKEN'], 'c2': os.environ['C2_TOKEN']}
path = os.path.join(os.environ['HOME'], '.openclaw/workspace/tools/usage-switch/tokens.json')
with open(path, 'w') as f:
    json.dump(data, f, indent=2)
print('✅ tokens.json に保存完了')
"
chmod 600 "$HOME/.openclaw/workspace/tools/usage-switch/tokens.json"
echo "✅ セットアップ完了"


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)