claude-statusline-setup 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # claude-statusline-setup
2
+
3
+ Setup Claude Code statusline with usage metrics display.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm i -g claude-statusline-setup
9
+ claude-statusline-setup
10
+ ```
11
+
12
+ ## What it does
13
+
14
+ Installs a custom statusline for Claude Code that shows:
15
+
16
+ - Current model name (Opus, Sonnet, Haiku)
17
+ - Git branch
18
+ - Context window usage
19
+ - Session usage (5-hour rolling limit)
20
+ - Weekly usage (7-day rolling limit)
21
+
22
+ Example output:
23
+ ```
24
+ Opus · main · Context 32% (65k/200k) · Session 70% @2pm · Week 6% @Jan 24, 9am
25
+ ```
26
+
27
+ ## Requirements
28
+
29
+ - macOS (uses Keychain for token storage)
30
+ - Claude Code CLI
31
+ - Python 3.x
32
+
33
+ ## Files installed
34
+
35
+ - `~/.claude/statusline-command.sh` - The statusline script
36
+ - `~/.claude/settings.json` - Updated with statusLine config
package/bin/install.js ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+
8
+ // ANSI colors
9
+ const GREEN = '\x1b[32m';
10
+ const YELLOW = '\x1b[33m';
11
+ const CYAN = '\x1b[36m';
12
+ const RED = '\x1b[31m';
13
+ const RESET = '\x1b[0m';
14
+ const BOLD = '\x1b[1m';
15
+
16
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
17
+ const STATUSLINE_FILE = 'statusline-command.sh';
18
+ const SETTINGS_FILE = 'settings.json';
19
+
20
+ function log(msg, color = '') {
21
+ console.log(`${color}${msg}${RESET}`);
22
+ }
23
+
24
+ function ensureClaudeDir() {
25
+ if (!fs.existsSync(CLAUDE_DIR)) {
26
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
27
+ log(`Created ${CLAUDE_DIR}`, GREEN);
28
+ }
29
+ }
30
+
31
+ function copyStatuslineScript() {
32
+ const src = path.join(__dirname, '..', 'files', STATUSLINE_FILE);
33
+ const dest = path.join(CLAUDE_DIR, STATUSLINE_FILE);
34
+
35
+ if (!fs.existsSync(src)) {
36
+ log(`Error: Source file not found: ${src}`, RED);
37
+ return false;
38
+ }
39
+
40
+ // Check if file already exists
41
+ if (fs.existsSync(dest)) {
42
+ const srcContent = fs.readFileSync(src, 'utf8');
43
+ const destContent = fs.readFileSync(dest, 'utf8');
44
+ if (srcContent === destContent) {
45
+ log(`${STATUSLINE_FILE} is already up to date`, CYAN);
46
+ return true;
47
+ }
48
+ // Backup existing file
49
+ const backupPath = `${dest}.backup.${Date.now()}`;
50
+ fs.copyFileSync(dest, backupPath);
51
+ log(`Backed up existing file to ${backupPath}`, YELLOW);
52
+ }
53
+
54
+ fs.copyFileSync(src, dest);
55
+ fs.chmodSync(dest, 0o755);
56
+ log(`Installed ${STATUSLINE_FILE} to ${CLAUDE_DIR}`, GREEN);
57
+ return true;
58
+ }
59
+
60
+ function updateSettings() {
61
+ const settingsPath = path.join(CLAUDE_DIR, SETTINGS_FILE);
62
+ let settings = {};
63
+
64
+ // Read existing settings if present
65
+ if (fs.existsSync(settingsPath)) {
66
+ try {
67
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
68
+ } catch (e) {
69
+ log(`Warning: Could not parse existing ${SETTINGS_FILE}`, YELLOW);
70
+ }
71
+ }
72
+
73
+ // Check if statusLine is already configured
74
+ const statusLineConfig = {
75
+ type: 'command',
76
+ command: `~/.claude/${STATUSLINE_FILE}`,
77
+ padding: 0
78
+ };
79
+
80
+ if (settings.statusLine &&
81
+ settings.statusLine.type === 'command' &&
82
+ settings.statusLine.command === statusLineConfig.command) {
83
+ log('statusLine is already configured', CYAN);
84
+ return;
85
+ }
86
+
87
+ // Update statusLine config
88
+ settings.statusLine = statusLineConfig;
89
+
90
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
91
+ log(`Updated ${SETTINGS_FILE} with statusLine config`, GREEN);
92
+ }
93
+
94
+ function showSuccess() {
95
+ console.log('');
96
+ log('='.repeat(50), CYAN);
97
+ log(`${BOLD}Claude Statusline Setup Complete!${RESET}`, GREEN);
98
+ log('='.repeat(50), CYAN);
99
+ console.log('');
100
+ log('Your statusline will show:', CYAN);
101
+ log(' - Current model name');
102
+ log(' - Git branch');
103
+ log(' - Context window usage');
104
+ log(' - Session usage (5-hour limit)');
105
+ log(' - Weekly usage (7-day limit)');
106
+ console.log('');
107
+ log('Restart Claude Code to see the changes.', YELLOW);
108
+ console.log('');
109
+ }
110
+
111
+ function main() {
112
+ log(`${BOLD}Claude Statusline Setup${RESET}`, CYAN);
113
+ log('-'.repeat(30), CYAN);
114
+ console.log('');
115
+
116
+ ensureClaudeDir();
117
+
118
+ if (copyStatuslineScript()) {
119
+ updateSettings();
120
+ showSuccess();
121
+ } else {
122
+ log('Setup failed!', RED);
123
+ process.exit(1);
124
+ }
125
+ }
126
+
127
+ main();
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Claude Code Statusline - 顯示 session 使用量資訊
4
+
5
+ 輸出格式:
6
+ Opus · main · Context 32% (65k/200k) · Session 70% @2pm · Week 6% @Jan 24, 9am
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from datetime import datetime, timedelta
15
+ from pathlib import Path
16
+ from urllib.request import Request, urlopen
17
+ from urllib.error import URLError, HTTPError
18
+
19
+ # ============================================================================
20
+ # 設定
21
+ # ============================================================================
22
+
23
+ CACHE_FILE = Path("/tmp/claude-usage-cache.json")
24
+ CACHE_TTL = 300 # 快取有效期(秒)
25
+ API_URL = "https://api.anthropic.com/api/oauth/usage"
26
+ TIMEZONE_OFFSET = 8 # Asia/Taipei UTC+8
27
+ DEFAULT_CONTEXT_SIZE = 200_000
28
+
29
+ # ============================================================================
30
+ # ANSI 色碼
31
+ # ============================================================================
32
+
33
+ GREEN = "\033[32m"
34
+ YELLOW = "\033[33m"
35
+ ORANGE = "\033[38;5;208m"
36
+ RED = "\033[31m"
37
+ CYAN = "\033[36m"
38
+ MAGENTA = "\033[35m"
39
+ BOLD = "\033[1m"
40
+ DIM = "\033[2m"
41
+ RESET = "\033[0m"
42
+
43
+ SEP = f" {DIM}·{RESET} "
44
+
45
+ # ============================================================================
46
+ # 顏色判斷
47
+ # ============================================================================
48
+
49
+ def get_color(percentage: float) -> str:
50
+ """根據使用量百分比回傳對應顏色"""
51
+ if percentage >= 80:
52
+ return RED
53
+ elif percentage >= 50:
54
+ return YELLOW
55
+ return GREEN
56
+
57
+
58
+ def get_context_color(percentage: float) -> str:
59
+ """根據 context 使用量百分比回傳對應顏色(更細緻的分級)"""
60
+ if percentage >= 80:
61
+ return RED
62
+ elif percentage >= 70:
63
+ return ORANGE
64
+ elif percentage >= 50:
65
+ return YELLOW
66
+ return GREEN
67
+
68
+ # ============================================================================
69
+ # Token 相關
70
+ # ============================================================================
71
+
72
+ def get_token_from_keychain() -> str | None:
73
+ """從 macOS Keychain 取得 OAuth token"""
74
+ try:
75
+ result = subprocess.run(
76
+ ["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
77
+ capture_output=True,
78
+ text=True,
79
+ timeout=5
80
+ )
81
+ if result.returncode == 0 and result.stdout.strip():
82
+ creds = json.loads(result.stdout.strip())
83
+ return creds.get("claudeAiOauth", {}).get("accessToken")
84
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
85
+ pass
86
+ return None
87
+
88
+
89
+ def get_token_from_file() -> str | None:
90
+ """從檔案取得 OAuth token"""
91
+ creds_file = Path.home() / ".claude" / ".credentials.json"
92
+ try:
93
+ if creds_file.exists():
94
+ with open(creds_file) as f:
95
+ creds = json.load(f)
96
+ return creds.get("claudeAiOauth", {}).get("accessToken")
97
+ except (json.JSONDecodeError, IOError):
98
+ pass
99
+ return None
100
+
101
+
102
+ def get_token() -> str | None:
103
+ """取得 OAuth token(優先 Keychain,fallback 檔案)"""
104
+ return get_token_from_keychain() or get_token_from_file()
105
+
106
+ # ============================================================================
107
+ # API 與快取
108
+ # ============================================================================
109
+
110
+ def fetch_usage(token: str) -> dict | None:
111
+ """呼叫 Usage API"""
112
+ headers = {
113
+ "Accept": "application/json",
114
+ "Content-Type": "application/json",
115
+ "User-Agent": "claude-code/2.0.32",
116
+ "Authorization": f"Bearer {token}",
117
+ "anthropic-beta": "oauth-2025-04-20",
118
+ }
119
+ try:
120
+ req = Request(API_URL, headers=headers, method="GET")
121
+ with urlopen(req, timeout=10) as response:
122
+ return json.loads(response.read().decode())
123
+ except (URLError, HTTPError, json.JSONDecodeError):
124
+ return None
125
+
126
+
127
+ def load_cache() -> dict | None:
128
+ """載入快取"""
129
+ try:
130
+ if CACHE_FILE.exists():
131
+ with open(CACHE_FILE) as f:
132
+ cache = json.load(f)
133
+ if time.time() - cache.get("timestamp", 0) < CACHE_TTL:
134
+ return cache.get("data")
135
+ except (json.JSONDecodeError, IOError):
136
+ pass
137
+ return None
138
+
139
+
140
+ def save_cache(data: dict) -> None:
141
+ """儲存快取"""
142
+ try:
143
+ with open(CACHE_FILE, "w") as f:
144
+ json.dump({"timestamp": time.time(), "data": data}, f)
145
+ except IOError:
146
+ pass
147
+
148
+ # ============================================================================
149
+ # 格式化工具
150
+ # ============================================================================
151
+
152
+ def format_hour(hour: int, minute: int) -> str:
153
+ """將小時和分鐘格式化為 12 小時制"""
154
+ if hour == 0:
155
+ hour_str, ampm = "12", "am"
156
+ elif hour < 12:
157
+ hour_str, ampm = str(hour), "am"
158
+ elif hour == 12:
159
+ hour_str, ampm = "12", "pm"
160
+ else:
161
+ hour_str, ampm = str(hour - 12), "pm"
162
+
163
+ if minute > 0:
164
+ return f"{hour_str}:{minute:02d}{ampm}"
165
+ return f"{hour_str}{ampm}"
166
+
167
+
168
+ def format_time(iso_time: str | None) -> str:
169
+ """將 UTC 時間轉換為本地時間格式(例:2pm, 1:59pm)"""
170
+ if not iso_time:
171
+ return "N/A"
172
+ try:
173
+ dt = datetime.fromisoformat(iso_time.replace("Z", "+00:00"))
174
+ local_dt = dt + timedelta(hours=TIMEZONE_OFFSET)
175
+ return format_hour(local_dt.hour, local_dt.minute)
176
+ except (ValueError, AttributeError):
177
+ return "N/A"
178
+
179
+
180
+ def format_week_reset(iso_time: str | None) -> str:
181
+ """將 UTC 時間轉換為日期格式(例:Jan 24, 9am)"""
182
+ if not iso_time:
183
+ return ""
184
+ try:
185
+ dt = datetime.fromisoformat(iso_time.replace("Z", "+00:00"))
186
+ local_dt = dt + timedelta(hours=TIMEZONE_OFFSET)
187
+
188
+ months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
189
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
190
+ month_str = months[local_dt.month - 1]
191
+ time_str = format_hour(local_dt.hour, local_dt.minute)
192
+
193
+ return f"{month_str} {local_dt.day}, {time_str}"
194
+ except (ValueError, AttributeError):
195
+ return ""
196
+
197
+
198
+ def format_tokens(tokens: int) -> str:
199
+ """格式化 token 數量(例:65k, 1.5M)"""
200
+ if tokens >= 1_000_000:
201
+ return f"{tokens / 1_000_000:.1f}M"
202
+ elif tokens >= 1_000:
203
+ return f"{tokens // 1_000}k"
204
+ return str(tokens)
205
+
206
+ # ============================================================================
207
+ # Context 計算
208
+ # ============================================================================
209
+
210
+ def calculate_context_usage(context_window: dict | None) -> tuple[int, int, float]:
211
+ """
212
+ 計算 context window 使用量
213
+ 回傳: (used_tokens, total_tokens, percentage)
214
+ """
215
+ if not context_window:
216
+ return 0, DEFAULT_CONTEXT_SIZE, 0.0
217
+
218
+ context_size = context_window.get("context_window_size", DEFAULT_CONTEXT_SIZE)
219
+
220
+ # 優先使用 current_usage(更準確)
221
+ current_usage = context_window.get("current_usage")
222
+ if current_usage:
223
+ used_tokens = (
224
+ current_usage.get("input_tokens", 0) +
225
+ current_usage.get("cache_read_input_tokens", 0) +
226
+ current_usage.get("cache_creation_input_tokens", 0)
227
+ )
228
+ else:
229
+ used_tokens = context_window.get("total_input_tokens", 0)
230
+
231
+ percentage = (used_tokens * 100) / context_size if context_size > 0 else 0
232
+ return used_tokens, context_size, percentage
233
+
234
+ # ============================================================================
235
+ # Git
236
+ # ============================================================================
237
+
238
+ def get_git_branch(cwd: str | None) -> str | None:
239
+ """取得 git 分支名稱"""
240
+ try:
241
+ result = subprocess.run(
242
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
243
+ capture_output=True,
244
+ text=True,
245
+ cwd=cwd or os.getcwd(),
246
+ timeout=2
247
+ )
248
+ if result.returncode == 0:
249
+ return result.stdout.strip()
250
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
251
+ pass
252
+ return None
253
+
254
+ # ============================================================================
255
+ # 主程式
256
+ # ============================================================================
257
+
258
+ def main():
259
+ # 讀取 stdin(Claude Code 傳入的 JSON)
260
+ cwd = None
261
+ model_name = None
262
+ context_window = None
263
+
264
+ try:
265
+ stdin_data = sys.stdin.read()
266
+ if stdin_data:
267
+ input_json = json.loads(stdin_data)
268
+ cwd = input_json.get("cwd") or input_json.get("workspace", {}).get("current_dir")
269
+ model_info = input_json.get("model", {})
270
+ model_name = model_info.get("display_name") or model_info.get("id")
271
+ context_window = input_json.get("context_window")
272
+ except (json.JSONDecodeError, IOError):
273
+ pass
274
+
275
+ # 取得各項資訊
276
+ git_branch = get_git_branch(cwd)
277
+ ctx_used, ctx_total, ctx_percent = calculate_context_usage(context_window)
278
+
279
+ # 取得 API 使用量(從快取或 API)
280
+ data = load_cache()
281
+ if not data:
282
+ token = get_token()
283
+ if not token:
284
+ print(f"{DIM}No token{RESET}")
285
+ return
286
+
287
+ data = fetch_usage(token)
288
+ if not data:
289
+ print(f"{DIM}API error{RESET}")
290
+ return
291
+
292
+ save_cache(data)
293
+
294
+ # 解析 API 資料
295
+ five_hour = data.get("five_hour", {})
296
+ seven_day = data.get("seven_day", {})
297
+
298
+ session_util = five_hour.get("utilization", 0)
299
+ session_reset = format_time(five_hour.get("resets_at"))
300
+ week_util = seven_day.get("utilization", 0)
301
+ week_reset = format_week_reset(seven_day.get("resets_at"))
302
+
303
+ # 取得顏色
304
+ session_color = get_color(session_util)
305
+ week_color = get_color(week_util)
306
+ ctx_color = get_context_color(ctx_percent)
307
+
308
+ # 組合輸出
309
+ parts = []
310
+
311
+ if model_name:
312
+ parts.append(f"{BOLD}{MAGENTA}{model_name}{RESET}")
313
+
314
+ if git_branch:
315
+ parts.append(f"{CYAN}{git_branch}{RESET}")
316
+
317
+ parts.append(
318
+ f"Context {ctx_color}{ctx_percent:.0f}%{RESET} "
319
+ f"{DIM}({format_tokens(ctx_used)}/{format_tokens(ctx_total)}){RESET}"
320
+ )
321
+
322
+ parts.append(
323
+ f"Session {session_color}{session_util:.0f}%{RESET} "
324
+ f"{DIM}@{session_reset}{RESET}"
325
+ )
326
+
327
+ week_reset_str = f" {DIM}@{week_reset}{RESET}" if week_reset else ""
328
+ parts.append(f"Week {week_color}{week_util:.0f}%{RESET}{week_reset_str}")
329
+
330
+ print(SEP.join(parts))
331
+
332
+
333
+ if __name__ == "__main__":
334
+ main()
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "claude-statusline-setup",
3
+ "version": "1.0.1",
4
+ "description": "Setup Claude Code statusline with usage metrics display",
5
+ "bin": {
6
+ "claude-statusline-setup": "./bin/install.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "files/"
11
+ ],
12
+ "keywords": [
13
+ "claude",
14
+ "claude-code",
15
+ "statusline",
16
+ "cli"
17
+ ],
18
+ "author": "Alan",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/lms0016/claude-statusline-setup.git"
23
+ },
24
+ "engines": {
25
+ "node": ">=16.0.0"
26
+ }
27
+ }