@thlinh/cc-statusline 2.0.0

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.
Binary file
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kamran Ahmed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # cc-statusline
2
+
3
+ Status line for Claude Code CLI. Works with Anthropic or Z.AI.
4
+
5
+ ![demo](./.github/demo.png)
6
+
7
+ Shows your current model, context percentage, git branch, how long you've been working, and API usage depending on your provider.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npx @thlinh/cc-statusline
13
+ ```
14
+
15
+ This uses Anthropic by default and installs to `~/.claude`.
16
+
17
+ For Z.AI:
18
+
19
+ ```bash
20
+ npx @thlinh/cc-statusline --provider=zai --dir ~/.claude-z
21
+ ```
22
+
23
+ For scripts or dotfiles (skip the prompts):
24
+
25
+ ```bash
26
+ npx @thlinh/cc-statusline --provider=zai --dir ~/.claude-z
27
+ ```
28
+
29
+ ## Requirements
30
+
31
+ - [jq](https://jqlang.github.io/jq/) — parses JSON
32
+ - curl — fetches usage
33
+ - git — branch info (optional)
34
+
35
+ macOS: `brew install jq`
36
+
37
+ ## Providers
38
+
39
+ **Anthropic** — pulls OAuth token from keychain, calls Anthropic's usage API, shows current/weekly/extra.
40
+
41
+ **Z.AI** — reads token from `~/.chelper/config.yaml`, calls Z.AI's quota API, shows token and tool usage.
42
+
43
+ ## Uninstall
44
+
45
+ ```bash
46
+ npx @thlinh/cc-statusline --uninstall --dir ~/.claude-z
47
+ ```
48
+
49
+ Restores your backup if you had one, otherwise removes the files.
50
+
51
+ ## What gets installed
52
+
53
+ Three files end up in your claude config directory:
54
+
55
+ - `statusline.sh` — main script (generated when you run the installer)
56
+ - `statusline-helpers.sh` — shared utilities
57
+ - `statusline-provider.sh` — whichever provider you picked
58
+
59
+ ## License
60
+
61
+ MIT
package/bin/install.js ADDED
@@ -0,0 +1,490 @@
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 blue = "\x1b[38;2;0;153;255m";
10
+ const green = "\x1b[38;2;0;175;80m";
11
+ const red = "\x1b[38;2;255;85;85m";
12
+ const yellow = "\x1b[38;2;230;200;0m";
13
+ const dim = "\x1b[2m";
14
+ const reset = "\x1b[0m";
15
+
16
+ function log(msg) {
17
+ console.log(` ${msg}`);
18
+ }
19
+
20
+ function success(msg) {
21
+ console.log(` ${green}✓${reset} ${msg}`);
22
+ }
23
+
24
+ function warn(msg) {
25
+ console.log(` ${yellow}!${reset} ${msg}`);
26
+ }
27
+
28
+ function fail(msg) {
29
+ console.error(` ${red}✗${reset} ${msg}`);
30
+ }
31
+
32
+ // Parse command line arguments
33
+ function parseArgs() {
34
+ const args = {};
35
+ for (let i = 2; i < process.argv.length; i++) {
36
+ const arg = process.argv[i];
37
+ if (arg === "--uninstall") {
38
+ args.uninstall = true;
39
+ } else if (arg.startsWith("--dir=")) {
40
+ args.dir = arg.split("=")[1];
41
+ } else if (arg.startsWith("--provider=")) {
42
+ args.provider = arg.split("=")[1];
43
+ }
44
+ }
45
+ return args;
46
+ }
47
+
48
+ // Check required dependencies
49
+ function checkDeps() {
50
+ const { execSync } = require("child_process");
51
+ const missing = [];
52
+
53
+ try {
54
+ execSync("which jq", { stdio: "ignore" });
55
+ } catch {
56
+ missing.push("jq");
57
+ }
58
+
59
+ try {
60
+ execSync("which curl", { stdio: "ignore" });
61
+ } catch {
62
+ missing.push("curl");
63
+ }
64
+
65
+ // Git is optional
66
+ let hasGit = false;
67
+ try {
68
+ execSync("which git", { stdio: "ignore" });
69
+ hasGit = true;
70
+ } catch {
71
+ // Git is optional
72
+ }
73
+
74
+ return { missing, hasGit };
75
+ }
76
+
77
+ // Prompt for provider selection
78
+ function promptProvider() {
79
+ const rl = readline.createInterface({
80
+ input: process.stdin,
81
+ output: process.stdout,
82
+ });
83
+
84
+ return new Promise((resolve) => {
85
+ rl.question(
86
+ `\n ${blue}Which API provider are you using?${reset}\n ${dim}1) Anthropic${reset}\n ${dim}2) Z.AI${reset}\n ${blue}>${reset} `,
87
+ (answer) => {
88
+ rl.close();
89
+ const choice = answer.trim();
90
+ if (choice === "1" || choice.toLowerCase().startsWith("a")) {
91
+ resolve("anthropic");
92
+ } else if (choice === "2" || choice.toLowerCase().startsWith("z")) {
93
+ resolve("zai");
94
+ } else {
95
+ warn("Invalid choice, defaulting to Anthropic");
96
+ resolve("anthropic");
97
+ }
98
+ }
99
+ );
100
+ });
101
+ }
102
+
103
+ // Get provider name (from flag or prompt)
104
+ async function getProvider(args) {
105
+ if (args.provider) {
106
+ const provider = args.provider.toLowerCase();
107
+ if (provider === "anthropic" || provider === "zai") {
108
+ return provider;
109
+ }
110
+ warn(`Unknown provider "${args.provider}", defaulting to Anthropic`);
111
+ return "anthropic";
112
+ }
113
+ return await promptProvider();
114
+ }
115
+
116
+ // Uninstall function
117
+ function uninstall(targetDir) {
118
+ const STATUSLINE_DEST = path.join(targetDir, "statusline.sh");
119
+ const HELPERS_DEST = path.join(targetDir, "statusline-helpers.sh");
120
+ const PROVIDER_DEST = path.join(targetDir, "statusline-provider.sh");
121
+ const CACHE_DEST = path.join(targetDir, "statusline-cache.json");
122
+ const SETTINGS_FILE = path.join(targetDir, "settings.json");
123
+ const BACKUP_DEST = STATUSLINE_DEST + ".bak";
124
+
125
+ console.log();
126
+ console.log(` ${blue}Claude Line Uninstaller${reset}`);
127
+ console.log(` ${dim}───────────────────────${reset}`);
128
+ console.log();
129
+
130
+ if (fs.existsSync(BACKUP_DEST)) {
131
+ // Restore from backup
132
+ fs.copyFileSync(BACKUP_DEST, STATUSLINE_DEST);
133
+ fs.unlinkSync(BACKUP_DEST);
134
+ success(`Restored backup from ${dim}statusline.sh.bak${reset}`);
135
+
136
+ // Only remove cache file (runtime-generated, always safe)
137
+ if (fs.existsSync(CACHE_DEST)) {
138
+ fs.unlinkSync(CACHE_DEST);
139
+ success(`Removed ${dim}statusline-cache.json${reset}`);
140
+ }
141
+
142
+ log(
143
+ `${dim}Note: Helper files (statusline-helpers.sh, statusline-provider.sh)${reset}`
144
+ );
145
+ log(
146
+ `${dim} were preserved as they may belong to the restored backup.${reset}`
147
+ );
148
+ log(
149
+ `${dim} Remove manually if not needed.${reset}`
150
+ );
151
+ } else if (fs.existsSync(STATUSLINE_DEST)) {
152
+ // No backup: remove all installed files
153
+ fs.unlinkSync(STATUSLINE_DEST);
154
+ success(`Removed ${dim}statusline.sh${reset}`);
155
+
156
+ if (fs.existsSync(HELPERS_DEST)) {
157
+ fs.unlinkSync(HELPERS_DEST);
158
+ success(`Removed ${dim}statusline-helpers.sh${reset}`);
159
+ }
160
+
161
+ if (fs.existsSync(PROVIDER_DEST)) {
162
+ fs.unlinkSync(PROVIDER_DEST);
163
+ success(`Removed ${dim}statusline-provider.sh${reset}`);
164
+ }
165
+
166
+ if (fs.existsSync(CACHE_DEST)) {
167
+ fs.unlinkSync(CACHE_DEST);
168
+ success(`Removed ${dim}statusline-cache.json${reset}`);
169
+ }
170
+ } else {
171
+ warn("No statusline found — nothing to remove");
172
+ }
173
+
174
+ // Remove statusLine from settings.json
175
+ if (fs.existsSync(SETTINGS_FILE)) {
176
+ try {
177
+ const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
178
+ const expectedCommand = `bash "${targetDir}/statusline.sh"`;
179
+
180
+ if (
181
+ settings.statusLine &&
182
+ settings.statusLine.type === "command" &&
183
+ settings.statusLine.command === expectedCommand
184
+ ) {
185
+ delete settings.statusLine;
186
+ fs.writeFileSync(
187
+ SETTINGS_FILE,
188
+ JSON.stringify(settings, null, 2) + "\n"
189
+ );
190
+ success(`Removed statusLine from ${dim}settings.json${reset}`);
191
+ }
192
+ } catch (err) {
193
+ fail(`Could not parse ${SETTINGS_FILE} — ${err.message}`);
194
+ }
195
+ }
196
+
197
+ console.log();
198
+ log(`${green}Done!${reset} Restart Claude Code to apply changes.`);
199
+ console.log();
200
+ }
201
+
202
+ // Generate statusline.sh content
203
+ function generateStatuslineScript(provider) {
204
+ const providerName = provider === "anthropic" ? "Anthropic" : "Z.AI";
205
+
206
+ return `#!/bin/bash
207
+ set -f
208
+
209
+ # Generated by cc-statusline installer
210
+ # Provider: ${providerName}
211
+ # DO NOT EDIT THIS FILE DIRECTLY - it will be overwritten on reinstall
212
+
213
+ input=$(cat)
214
+
215
+ if [ -z "$input" ]; then
216
+ printf "Claude"
217
+ exit 0
218
+ fi
219
+
220
+ # Self-locate script directory
221
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
222
+
223
+ # Source shared helpers
224
+ . "\${SCRIPT_DIR}/statusline-helpers.sh"
225
+
226
+ # Source provider implementation
227
+ . "\${SCRIPT_DIR}/statusline-provider.sh"
228
+
229
+ # ── Extract JSON data ───────────────────────────────────
230
+ model_name=$(echo "$input" | jq -r '.model.display_name // "Claude"')
231
+
232
+ size=$(echo "$input" | jq -r '.context_window.context_window_size // 200000')
233
+ [ "$size" -eq 0 ] 2>/dev/null && size=200000
234
+
235
+ input_tokens=$(echo "$input" | jq -r '.context_window.current_usage.input_tokens // 0')
236
+ cache_create=$(echo "$input" | jq -r '.context_window.current_usage.cache_creation_input_tokens // 0')
237
+ cache_read=$(echo "$input" | jq -r '.context_window.current_usage.cache_read_input_tokens // 0')
238
+ current=$(( input_tokens + cache_create + cache_read ))
239
+
240
+ used_tokens=$(format_tokens $current)
241
+ total_tokens=$(format_tokens $size)
242
+
243
+ if [ "$size" -gt 0 ]; then
244
+ pct_used=$(( current * 100 / size ))
245
+ else
246
+ pct_used=0
247
+ fi
248
+
249
+ effort="default"
250
+ settings_path="\${SCRIPT_DIR}/settings.json"
251
+ if [ -f "$settings_path" ]; then
252
+ effort=$(jq -r '.effortLevel // "default"' "$settings_path" 2>/dev/null)
253
+ fi
254
+
255
+ # ── LINE 1: Model │ Context % │ Directory (branch) │ Session │ Effort ──
256
+ sep=" \${dim}│\${reset} "
257
+ pct_color=$(color_for_pct "$pct_used")
258
+ cwd=$(echo "$input" | jq -r '.cwd // ""')
259
+ [ -z "$cwd" ] || [ "$cwd" = "null" ] && cwd=$(pwd)
260
+ dirname=$(basename "$cwd")
261
+
262
+ git_branch=""
263
+ git_dirty=""
264
+ if command -v git >/dev/null 2>&1 && git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
265
+ git_branch=$(git -C "$cwd" symbolic-ref --short HEAD 2>/dev/null)
266
+ if [ -n "$(git -C "$cwd" --no-optional-locks status --porcelain 2>/dev/null)" ]; then
267
+ git_dirty="*"
268
+ fi
269
+ fi
270
+
271
+ session_duration=""
272
+ session_start=$(echo "$input" | jq -r '.session.start_time // empty')
273
+ if [ -n "$session_start" ] && [ "$session_start" != "null" ]; then
274
+ start_epoch=$(iso_to_epoch "$session_start")
275
+ if [ -n "$start_epoch" ]; then
276
+ now_epoch=$(date +%s)
277
+ elapsed=$(( now_epoch - start_epoch ))
278
+ if [ "$elapsed" -ge 3600 ]; then
279
+ session_duration="$(( elapsed / 3600 ))h$(( (elapsed % 3600) / 60 ))m"
280
+ elif [ "$elapsed" -ge 60 ]; then
281
+ session_duration="$(( elapsed / 60 ))m"
282
+ else
283
+ session_duration="\${elapsed}s"
284
+ fi
285
+ fi
286
+ fi
287
+
288
+ line1="\${blue}\${model_name}\${reset}"
289
+ line1+="\${sep}"
290
+ line1+="🧠 \${pct_color}\${pct_used}%${reset}"
291
+ line1+="\${sep}"
292
+ line1+="\${cyan}\${dirname}\${reset}"
293
+ if [ -n "$git_branch" ]; then
294
+ line1+=" \${green}(\${git_branch}\${red}\${git_dirty}\${green})\${reset}"
295
+ fi
296
+ if [ -n "$session_duration" ]; then
297
+ line1+="\${sep}"
298
+ line1+="\${dim}⏱ \${reset}\${white}\${session_duration}\${reset}"
299
+ fi
300
+ line1+="\${sep}"
301
+ case "$effort" in
302
+ high) line1+="\${magenta}● \${effort}\${reset}" ;;
303
+ medium) line1+="\${dim}◑ \${effort}\${reset}" ;;
304
+ low) line1+="\${dim}◔ \${effort}\${reset}" ;;
305
+ *) line1+="\${dim}◑ \${effort}\${reset}" ;;
306
+ esac
307
+
308
+ # ── Fetch provider usage data (cached) ──────────────────
309
+ cache_file="\${SCRIPT_DIR}/statusline-cache.json"
310
+ cache_max_age=60
311
+ needs_refresh=true
312
+ usage_data=""
313
+
314
+ if [ -f "$cache_file" ]; then
315
+ cache_mtime=$(stat -c %Y "$cache_file" 2>/dev/null || stat -f %m "$cache_file" 2>/dev/null)
316
+ now=$(date +%s)
317
+ cache_age=$(( now - cache_mtime ))
318
+ if [ "$cache_age" -lt "$cache_max_age" ]; then
319
+ # Check if cached provider matches current provider
320
+ cached_provider=$(jq -r '.provider // empty' "$cache_file" 2>/dev/null)
321
+ if [ "$cached_provider" = "${provider}" ]; then
322
+ needs_refresh=false
323
+ usage_data=$(cat "$cache_file" 2>/dev/null | jq -r '.data // empty')
324
+ fi
325
+ fi
326
+ fi
327
+
328
+ if $needs_refresh; then
329
+ token=$(get_provider_token)
330
+ if [ -n "$token" ] && [ "$token" != "null" ]; then
331
+ response=$(fetch_usage_data "$token")
332
+ if [ -n "$response" ]; then
333
+ usage_data="$response"
334
+ echo '{"provider": "${provider}", "fetched_at": '$(date +%s)', "data": '$(echo "$response" | jq -c .)'}' > "$cache_file"
335
+ fi
336
+ fi
337
+ if [ -z "$usage_data" ] && [ -f "$cache_file" ]; then
338
+ usage_data=$(cat "$cache_file" 2>/dev/null | jq -r '.data // empty')
339
+ fi
340
+ fi
341
+
342
+ # ── Provider usage lines ─────────────────────────────────
343
+ rate_lines=""
344
+ if [ -n "$usage_data" ] && echo "$usage_data" | jq -e . >/dev/null 2>&1; then
345
+ rate_lines=$(format_usage_lines "$usage_data")
346
+ fi
347
+
348
+ # ── Output ──────────────────────────────────────────────
349
+ printf "%b" "$line1"
350
+ [ -n "$rate_lines" ] && printf "\\n\\n%b" "$rate_lines"
351
+
352
+ exit 0
353
+ `;
354
+ }
355
+
356
+ // Main install function
357
+ async function run() {
358
+ const args = parseArgs();
359
+
360
+ // Default directory
361
+ const targetDir = args.dir || path.join(os.homedir(), ".claude");
362
+
363
+ // Uninstall mode
364
+ if (args.uninstall) {
365
+ uninstall(targetDir);
366
+ return;
367
+ }
368
+
369
+ // Install mode
370
+ console.log();
371
+ console.log(` ${blue}Claude Line Installer${reset}`);
372
+ console.log(` ${dim}─────────────────────${reset}`);
373
+ console.log();
374
+
375
+ // Check dependencies
376
+ const { missing, hasGit } = checkDeps();
377
+ if (missing.length > 0) {
378
+ fail(`Missing required dependencies: ${missing.join(", ")}`);
379
+ log(` Install them and try again.`);
380
+ if (missing.includes("jq")) {
381
+ log(` ${dim}brew install jq${reset}`);
382
+ }
383
+ process.exit(1);
384
+ }
385
+ success("Dependencies found (jq, curl)");
386
+
387
+ if (!hasGit) {
388
+ warn("git not found - branch display will be disabled");
389
+ }
390
+
391
+ // Get provider
392
+ const provider = await getProvider(args);
393
+ const providerName = provider === "anthropic" ? "Anthropic" : "Z.AI";
394
+ success(`Installing for ${providerName}`);
395
+
396
+ // Create target directory if needed
397
+ if (!fs.existsSync(targetDir)) {
398
+ fs.mkdirSync(targetDir, { recursive: true });
399
+ success(`Created ${targetDir}`);
400
+ }
401
+
402
+ // File paths
403
+ const STATUSLINE_DEST = path.join(targetDir, "statusline.sh");
404
+ const HELPERS_DEST = path.join(targetDir, "statusline-helpers.sh");
405
+ const PROVIDER_DEST = path.join(targetDir, "statusline-provider.sh");
406
+ const SETTINGS_FILE = path.join(targetDir, "settings.json");
407
+ const BACKUP_DEST = STATUSLINE_DEST + ".bak";
408
+
409
+ // Source files
410
+ const HELPERS_SRC = path.resolve(__dirname, "shared-helpers.sh");
411
+ const PROVIDER_SRC = path.resolve(__dirname, "providers", `${provider}.sh`);
412
+
413
+ // Check source files exist
414
+ if (!fs.existsSync(HELPERS_SRC)) {
415
+ fail(`Source file not found: ${HELPERS_SRC}`);
416
+ process.exit(1);
417
+ }
418
+ if (!fs.existsSync(PROVIDER_SRC)) {
419
+ fail(`Source file not found: ${PROVIDER_SRC}`);
420
+ process.exit(1);
421
+ }
422
+
423
+ // Backup existing statusline.sh
424
+ if (fs.existsSync(STATUSLINE_DEST)) {
425
+ fs.copyFileSync(STATUSLINE_DEST, BACKUP_DEST);
426
+ warn(`Backed up existing statusline to ${dim}statusline.sh.bak${reset}`);
427
+ }
428
+
429
+ // Copy shared helpers
430
+ fs.copyFileSync(HELPERS_SRC, HELPERS_DEST);
431
+ fs.chmodSync(HELPERS_DEST, 0o644);
432
+ success(`Installed helpers to ${dim}${HELPERS_DEST}${reset}`);
433
+
434
+ // Copy provider script
435
+ fs.copyFileSync(PROVIDER_SRC, PROVIDER_DEST);
436
+ fs.chmodSync(PROVIDER_DEST, 0o644);
437
+ success(
438
+ `Installed ${providerName} provider to ${dim}${PROVIDER_DEST}${reset}`
439
+ );
440
+
441
+ // Generate and write statusline.sh
442
+ const statuslineContent = generateStatuslineScript(provider);
443
+ fs.writeFileSync(STATUSLINE_DEST, statuslineContent);
444
+ fs.chmodSync(STATUSLINE_DEST, 0o755);
445
+ success(`Installed statusline to ${dim}${STATUSLINE_DEST}${reset}`);
446
+
447
+ // Update settings.json
448
+ let settings = {};
449
+ if (fs.existsSync(SETTINGS_FILE)) {
450
+ try {
451
+ settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
452
+ } catch {
453
+ fail(`Could not parse ${SETTINGS_FILE} — fix it manually`);
454
+ process.exit(1);
455
+ }
456
+ }
457
+
458
+ const statusLineConfig = {
459
+ type: "command",
460
+ command: `bash "${targetDir}/statusline.sh"`,
461
+ };
462
+
463
+ if (
464
+ settings.statusLine &&
465
+ settings.statusLine.type === "command" &&
466
+ settings.statusLine.command === statusLineConfig.command
467
+ ) {
468
+ success("Settings already configured");
469
+ } else {
470
+ settings.statusLine = statusLineConfig;
471
+ fs.writeFileSync(
472
+ SETTINGS_FILE,
473
+ JSON.stringify(settings, null, 2) + "\n"
474
+ );
475
+ success(`Updated ${dim}settings.json${reset} with statusLine config`);
476
+ }
477
+
478
+ console.log();
479
+ log(
480
+ `${green}Done!${reset} Restart Claude Code to see your new status line.`
481
+ );
482
+ console.log();
483
+ log(`${dim}Provider: ${providerName}${reset}`);
484
+ console.log();
485
+ }
486
+
487
+ run().catch((err) => {
488
+ fail(err.message);
489
+ process.exit(1);
490
+ });
@@ -0,0 +1,127 @@
1
+ #!/bin/bash
2
+ # Anthropic provider for cc-statusline
3
+ # Implements: get_provider_token, fetch_usage_data, format_usage_lines
4
+
5
+ # ── Get Anthropic OAuth token ─────────────────────────────
6
+ get_provider_token() {
7
+ local token=""
8
+
9
+ # Check environment variable first
10
+ if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ]; then
11
+ echo "$CLAUDE_CODE_OAUTH_TOKEN"
12
+ return 0
13
+ fi
14
+
15
+ # Check macOS keychain
16
+ if command -v security >/dev/null 2>&1; then
17
+ local blob
18
+ blob=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null)
19
+ if [ -n "$blob" ]; then
20
+ token=$(echo "$blob" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)
21
+ if [ -n "$token" ] && [ "$token" != "null" ]; then
22
+ echo "$token"
23
+ return 0
24
+ fi
25
+ fi
26
+ fi
27
+
28
+ # Check credentials file
29
+ local creds_file="${HOME}/.claude/.credentials.json"
30
+ if [ -f "$creds_file" ]; then
31
+ token=$(jq -r '.claudeAiOauth.accessToken // empty' "$creds_file" 2>/dev/null)
32
+ if [ -n "$token" ] && [ "$token" != "null" ]; then
33
+ echo "$token"
34
+ return 0
35
+ fi
36
+ fi
37
+
38
+ # Check Linux secret-tool
39
+ if command -v secret-tool >/dev/null 2>&1; then
40
+ local blob
41
+ blob=$(timeout 2 secret-tool lookup service "Claude Code-credentials" 2>/dev/null)
42
+ if [ -n "$blob" ]; then
43
+ token=$(echo "$blob" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)
44
+ if [ -n "$token" ] && [ "$token" != "null" ]; then
45
+ echo "$token"
46
+ return 0
47
+ fi
48
+ fi
49
+ fi
50
+
51
+ echo ""
52
+ }
53
+
54
+ # ── Fetch Anthropic usage data ────────────────────────────
55
+ fetch_usage_data() {
56
+ local token="$1"
57
+ [ -z "$token" ] && return 1
58
+
59
+ local response
60
+ response=$(curl -s --max-time 5 \
61
+ -H "Accept: application/json" \
62
+ -H "Content-Type: application/json" \
63
+ -H "Authorization: Bearer $token" \
64
+ -H "anthropic-beta: oauth-2025-04-20" \
65
+ -H "User-Agent: claude-code/2.1.34" \
66
+ "https://api.anthropic.com/api/oauth/usage" 2>/dev/null)
67
+
68
+ if [ -n "$response" ] && echo "$response" | jq -e '.five_hour' >/dev/null 2>&1; then
69
+ echo "$response"
70
+ return 0
71
+ fi
72
+
73
+ return 1
74
+ }
75
+
76
+ # ── Format Anthropic usage lines ──────────────────────────
77
+ format_usage_lines() {
78
+ local usage_data="$1"
79
+ [ -z "$usage_data" ] && return
80
+
81
+ local bar_width=10
82
+ local rate_lines=""
83
+
84
+ # Five hour (current) usage
85
+ local five_hour_pct five_hour_reset_iso five_hour_reset five_hour_bar five_hour_pct_color five_hour_pct_fmt
86
+ five_hour_pct=$(echo "$usage_data" | jq -r '.five_hour.utilization // 0' | awk '{printf "%.0f", $1}')
87
+ five_hour_reset_iso=$(echo "$usage_data" | jq -r '.five_hour.resets_at // empty')
88
+ five_hour_reset=$(format_reset_time "$five_hour_reset_iso" "time")
89
+ five_hour_bar=$(build_bar "$five_hour_pct" "$bar_width")
90
+ five_hour_pct_color=$(color_for_pct "$five_hour_pct")
91
+ five_hour_pct_fmt=$(printf "%3d" "$five_hour_pct")
92
+
93
+ rate_lines+="${white}current${reset} ${five_hour_bar} ${five_hour_pct_color}${five_hour_pct_fmt}%${reset} ${dim}⟳${reset} ${white}${five_hour_reset}${reset}"
94
+
95
+ # Seven day (weekly) usage
96
+ local seven_day_pct seven_day_reset_iso seven_day_reset seven_day_bar seven_day_pct_color seven_day_pct_fmt
97
+ seven_day_pct=$(echo "$usage_data" | jq -r '.seven_day.utilization // 0' | awk '{printf "%.0f", $1}')
98
+ seven_day_reset_iso=$(echo "$usage_data" | jq -r '.seven_day.resets_at // empty')
99
+ seven_day_reset=$(format_reset_time "$seven_day_reset_iso" "datetime")
100
+ seven_day_bar=$(build_bar "$seven_day_pct" "$bar_width")
101
+ seven_day_pct_color=$(color_for_pct "$seven_day_pct")
102
+ seven_day_pct_fmt=$(printf "%3d" "$seven_day_pct")
103
+
104
+ rate_lines+="\n${white}weekly${reset} ${seven_day_bar} ${seven_day_pct_color}${seven_day_pct_fmt}%${reset} ${dim}⟳${reset} ${white}${seven_day_reset}${reset}"
105
+
106
+ # Extra usage (monthly credits) - if enabled
107
+ local extra_enabled extra_pct extra_used extra_limit extra_bar extra_pct_color extra_reset extra_col
108
+ extra_enabled=$(echo "$usage_data" | jq -r '.extra_usage.is_enabled // false')
109
+ if [ "$extra_enabled" = "true" ]; then
110
+ extra_pct=$(echo "$usage_data" | jq -r '.extra_usage.utilization // 0' | awk '{printf "%.0f", $1}')
111
+ extra_used=$(echo "$usage_data" | jq -r '.extra_usage.used_credits // 0' | awk '{printf "%.2f", $1/100}')
112
+ extra_limit=$(echo "$usage_data" | jq -r '.extra_usage.monthly_limit // 0' | awk '{printf "%.2f", $1/100}')
113
+ extra_bar=$(build_bar "$extra_pct" "$bar_width")
114
+ extra_pct_color=$(color_for_pct "$extra_pct")
115
+
116
+ # Calculate next month reset
117
+ extra_reset=$(date -v+1m -v1d +"%b %-d" 2>/dev/null | tr '[:upper:]' '[:lower:]')
118
+ if [ -z "$extra_reset" ]; then
119
+ extra_reset=$(date -d "$(date +%Y-%m-01) +1 month" +"%b %-d" 2>/dev/null | tr '[:upper:]' '[:lower:]')
120
+ fi
121
+
122
+ extra_col="${white}extra${reset} ${extra_bar} ${extra_pct_color}\$${extra_used}${dim}/${reset}${white}\$${extra_limit}${reset} ${dim}⟳${reset} ${white}${extra_reset}${reset}"
123
+ rate_lines+="\n${extra_col}"
124
+ fi
125
+
126
+ printf "%b" "$rate_lines"
127
+ }
@@ -0,0 +1,98 @@
1
+ #!/bin/bash
2
+ # Z.AI provider for cc-statusline
3
+ # Implements: get_provider_token, fetch_usage_data, format_usage_lines
4
+
5
+ # ── Get Z.AI API token from config.yaml ───────────────────
6
+ get_provider_token() {
7
+ local config_file="${HOME}/.chelper/config.yaml"
8
+ [ ! -f "$config_file" ] && echo "" && return 1
9
+
10
+ # Extract api_key using basic string parsing (no yaml parser dependency)
11
+ local token
12
+ token=$(grep "^api_key:" "$config_file" | sed 's/^api_key:[[:space:]]*//' | tr -d '[:space:]"'"'')
13
+
14
+ if [ -n "$token" ]; then
15
+ echo "$token"
16
+ return 0
17
+ fi
18
+
19
+ echo ""
20
+ return 1
21
+ }
22
+
23
+ # ── Fetch Z.AI usage data ──────────────────────────────────
24
+ fetch_usage_data() {
25
+ local token="$1"
26
+ [ -z "$token" ] && return 1
27
+
28
+ local response
29
+ response=$(curl -s --max-time 5 \
30
+ -H "Accept: application/json" \
31
+ -H "Content-Type: application/json" \
32
+ -H "Authorization: Bearer $token" \
33
+ "https://api.z.ai/api/monitor/usage/quota/limit" 2>/dev/null)
34
+
35
+ if [ -n "$response" ] && echo "$response" | jq -e '.data' >/dev/null 2>&1; then
36
+ echo "$response"
37
+ return 0
38
+ fi
39
+
40
+ return 1
41
+ }
42
+
43
+ # ── Format Z.AI usage lines ────────────────────────────────
44
+ format_usage_lines() {
45
+ local usage_data="$1"
46
+ [ -z "$usage_data" ] && return
47
+
48
+ local bar_width=10
49
+ local rate_lines=""
50
+
51
+ # TOKENS_LIMIT (current)
52
+ local tokens_used tokens_limit tokens_pct tokens_reset_iso tokens_reset tokens_bar tokens_pct_color tokens_pct_fmt tokens_display
53
+ tokens_used=$(echo "$usage_data" | jq -r '.data.TOKENS_LIMIT.used // 0')
54
+ tokens_limit=$(echo "$usage_data" | jq -r '.data.TOKENS_LIMIT.limit // 1')
55
+ tokens_reset_iso=$(echo "$usage_data" | jq -r '.data.TOKENS_LIMIT.reset_time // empty')
56
+
57
+ if [ "$tokens_limit" -gt 0 ]; then
58
+ tokens_pct=$(( tokens_used * 100 / tokens_limit ))
59
+ else
60
+ tokens_pct=0
61
+ fi
62
+
63
+ tokens_reset=$(format_reset_time "$tokens_reset_iso" "time")
64
+ tokens_bar=$(build_bar "$tokens_pct" "$bar_width")
65
+ tokens_pct_color=$(color_for_pct "$tokens_pct")
66
+ tokens_pct_fmt=$(printf "%3d" "$tokens_pct")
67
+ tokens_display=$(format_tokens "$tokens_used")
68
+
69
+ rate_lines+="${white}current${reset} ${tokens_bar} ${tokens_pct_color}${tokens_pct_fmt}%${reset} ${dim}(${reset}${white}${tokens_display}${reset}${dim})${reset} ${dim}⟳${reset} ${white}${tokens_reset}${reset}"
70
+
71
+ # TIME_LIMIT (tools/MCP) - no reset time shown
72
+ local time_used time_limit time_pct time_bar time_pct_color time_pct_fmt time_seconds
73
+ time_used=$(echo "$usage_data" | jq -r '.data.TIME_LIMIT.used // 0')
74
+ time_limit=$(echo "$usage_data" | jq -r '.data.TIME_LIMIT.limit // 1')
75
+
76
+ if [ "$time_limit" -gt 0 ]; then
77
+ time_pct=$(( time_used * 100 / time_limit ))
78
+ else
79
+ time_pct=0
80
+ fi
81
+
82
+ time_bar=$(build_bar "$time_pct" "$bar_width")
83
+ time_pct_color=$(color_for_pct "$time_pct")
84
+ time_pct_fmt=$(printf "%3d" "$time_pct")
85
+
86
+ # Convert seconds to readable format
87
+ if [ "$time_used" -ge 3600 ]; then
88
+ time_seconds="$(( time_used / 3600 ))h$(( (time_used % 3600) / 60 ))m"
89
+ elif [ "$time_used" -ge 60 ]; then
90
+ time_seconds="$(( time_used / 60 ))m"
91
+ else
92
+ time_seconds="${time_used}s"
93
+ fi
94
+
95
+ rate_lines+="\n${white}tools${reset} ${time_bar} ${time_pct_color}${time_pct_fmt}%${reset} ${dim}(${reset}${white}${time_seconds}${reset}${dim})${reset}"
96
+
97
+ printf "%b" "$rate_lines"
98
+ }
@@ -0,0 +1,117 @@
1
+ #!/bin/bash
2
+ # Shared helper functions for cc-statusline
3
+ # This file is copied to ~/.claude/ or ~/.claude-z/ at install time
4
+
5
+ # ── Colors ──────────────────────────────────────────────
6
+ blue='\033[38;2;0;153;255m'
7
+ orange='\033[38;2;255;176;85m'
8
+ green='\033[38;2;0;175;80m'
9
+ cyan='\033[38;2;86;182;194m'
10
+ red='\033[38;2;255;85;85m'
11
+ yellow='\033[38;2;230;200;0m'
12
+ white='\033[38;2;220;220;220m'
13
+ magenta='\033[38;2;180;140;255m'
14
+ dim='\033[2m'
15
+ reset='\033[0m'
16
+
17
+ # ── Format token counts ───────────────────────────────────
18
+ format_tokens() {
19
+ local num=$1
20
+ if [ "$num" -ge 1000000 ]; then
21
+ awk "BEGIN {printf \"%.1fm\", $num / 1000000}"
22
+ elif [ "$num" -ge 1000 ]; then
23
+ awk "BEGIN {printf \"%.0fk\", $num / 1000}"
24
+ else
25
+ printf "%d" "$num"
26
+ fi
27
+ }
28
+
29
+ # ── Get color based on percentage ─────────────────────────
30
+ color_for_pct() {
31
+ local pct=$1
32
+ if [ "$pct" -ge 90 ]; then printf "$red"
33
+ elif [ "$pct" -ge 70 ]; then printf "$yellow"
34
+ elif [ "$pct" -ge 50 ]; then printf "$orange"
35
+ else printf "$green"
36
+ fi
37
+ }
38
+
39
+ # ── Build visual bar ─────────────────────────────────────
40
+ build_bar() {
41
+ local pct=$1
42
+ local width=$2
43
+ [ "$pct" -lt 0 ] 2>/dev/null && pct=0
44
+ [ "$pct" -gt 100 ] 2>/dev/null && pct=100
45
+
46
+ local filled=$(( pct * width / 100 ))
47
+ local empty=$(( width - filled ))
48
+ local bar_color
49
+ bar_color=$(color_for_pct "$pct")
50
+
51
+ local filled_str="" empty_str=""
52
+ for ((i=0; i<filled; i++)); do filled_str+="●"; done
53
+ for ((i=0; i<empty; i++)); do empty_str+="○"; done
54
+
55
+ printf "${bar_color}${filled_str}${dim}${empty_str}${reset}"
56
+ }
57
+
58
+ # ── Convert ISO 8601 to epoch (cross-platform) ───────────
59
+ iso_to_epoch() {
60
+ local iso_str="$1"
61
+
62
+ local epoch
63
+ # Try GNU date first (Linux)
64
+ epoch=$(date -d "${iso_str}" +%s 2>/dev/null)
65
+ if [ -n "$epoch" ]; then
66
+ echo "$epoch"
67
+ return 0
68
+ fi
69
+
70
+ # Strip milliseconds and timezone for macOS date
71
+ local stripped="${iso_str%%.*}"
72
+ stripped="${stripped%%Z}"
73
+ stripped="${stripped%%+*}"
74
+ stripped="${stripped%%-[0-9][0-9]:[0-9][0-9]}"
75
+
76
+ # Try macOS date with timezone handling
77
+ if [[ "$iso_str" == *"Z"* ]] || [[ "$iso_str" == *"+00:00"* ]] || [[ "$iso_str" == *"-00:00"* ]]; then
78
+ epoch=$(env TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%S" "$stripped" +%s 2>/dev/null)
79
+ else
80
+ epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$stripped" +%s 2>/dev/null)
81
+ fi
82
+
83
+ if [ -n "$epoch" ]; then
84
+ echo "$epoch"
85
+ return 0
86
+ fi
87
+
88
+ return 1
89
+ }
90
+
91
+ # ── Format reset times ───────────────────────────────────
92
+ format_reset_time() {
93
+ local iso_str="$1"
94
+ local style="$2"
95
+ [ -z "$iso_str" ] || [ "$iso_str" = "null" ] && return
96
+
97
+ local epoch
98
+ epoch=$(iso_to_epoch "$iso_str")
99
+ [ -z "$epoch" ] && return
100
+
101
+ local result=""
102
+ case "$style" in
103
+ time)
104
+ result=$(date -j -r "$epoch" +"%l:%M%p" 2>/dev/null | sed 's/^ //; s/\.//g' | tr '[:upper:]' '[:lower:]')
105
+ [ -z "$result" ] && result=$(date -d "@$epoch" +"%l:%M%P" 2>/dev/null | sed 's/^ //; s/\.//g')
106
+ ;;
107
+ datetime)
108
+ result=$(date -j -r "$epoch" +"%b %-d, %l:%M%p" 2>/dev/null | sed 's/ / /g; s/^ //; s/\.//g' | tr '[:upper:]' '[:lower:]')
109
+ [ -z "$result" ] && result=$(date -d "@$epoch" +"%b %-d, %l:%M%P" 2>/dev/null | sed 's/ / /g; s/^ //; s/\.//g')
110
+ ;;
111
+ *)
112
+ result=$(date -j -r "$epoch" +"%b %-d" 2>/dev/null | tr '[:upper:]' '[:lower:]')
113
+ [ -z "$result" ] && result=$(date -d "@$epoch" +"%b %-d" 2>/dev/null)
114
+ ;;
115
+ esac
116
+ printf "%s" "$result"
117
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@thlinh/cc-statusline",
3
+ "version": "2.0.0",
4
+ "description": "Multi-provider status line for Claude Code CLI supporting Anthropic and Z.AI APIs",
5
+ "bin": {
6
+ "cc-statusline": "./bin/install.js"
7
+ },
8
+ "keywords": [
9
+ "claude",
10
+ "claude-code",
11
+ "statusline",
12
+ "cli",
13
+ "anthropic",
14
+ "zai",
15
+ "multi-provider"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Linh Truong",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/thlinhit/cc-statusline.git"
22
+ }
23
+ }