diff --git a/README.md b/README.md index f918b7e..23babb4 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,14 @@ ## 🆕 Recent Updates -### v2.2.0 - v2.2.1 - Token Speed + Skills widget updates +### v2.2.0 - v2.2.2 - Token Speed + Skills widget updates - **🚀 New Token Speed widgets** - Added three widgets: **Input Speed**, **Output Speed**, and **Total Speed**. - Each speed widget supports a configurable window of `0-120` seconds in the widget editor (`w` key). - `0` disables window mode and uses a full-session average speed. - `1-120` calculates recent speed over the selected rolling window. - **🧩 New Skills widget controls (v2.2.1)** - Added configurable Skills modes (last/count/list), optional hide-when-empty behavior, and list-size limiting with most-recent-first ordering. +- **🌐 Usage API proxy support (v2.2.2)** - Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic. - **🤝 Better subagent-aware speed reporting** - Token speed calculations continue to include referenced subagent activity so displayed speeds better reflect actual concurrent work. ### v2.1.0 - v2.1.10 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes @@ -203,6 +204,8 @@ The interactive configuration tool provides a terminal UI where you can: > $env:CLAUDE_CONFIG_DIR="C:\custom\path\.claude" > ``` +> 🌐 **Usage API proxy:** Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic. + ### Claude Code settings.json format When you install from the TUI, ccstatusline writes a `statusLine` command object to your Claude Code settings: diff --git a/bun.lock b/bun.lock index 8dc7b85..2f19b85 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "eslint-plugin-import-newlines": "^1.4.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", + "https-proxy-agent": "^7.0.0", "ink": "6.2.0", "ink-gradient": "^3.0.0", "ink-select-input": "^6.2.0", @@ -332,6 +333,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], @@ -566,6 +569,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -978,6 +983,8 @@ "gradient-string/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "ink-gradient/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], "is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], diff --git a/package.json b/package.json index 350234e..bfb7f41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.2.1", + "version": "2.2.2", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", @@ -34,6 +34,7 @@ "eslint-plugin-import-newlines": "^1.4.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", + "https-proxy-agent": "^7.0.0", "ink": "6.2.0", "ink-gradient": "^3.0.0", "ink-select-input": "^6.2.0", diff --git a/src/utils/__tests__/config.test.ts b/src/utils/__tests__/config.test.ts index 2ec9be7..352daa3 100644 --- a/src/utils/__tests__/config.test.ts +++ b/src/utils/__tests__/config.test.ts @@ -171,4 +171,4 @@ describe('config utilities', () => { expect(saved.version).toBe(CURRENT_VERSION); expect(consoleErrorSpy).not.toHaveBeenCalled(); }); -}); +}); \ No newline at end of file diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index 22081de..548ad5d 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -15,6 +15,8 @@ interface UsageProbeResult { lockExists: boolean; cacheExists: boolean; requestCount: number; + proxyAgentConfigured: boolean; + requestHost: string | null; } describe('fetchUsageData error handling', () => { @@ -27,6 +29,7 @@ describe('fetchUsageData error handling', () => { const apiErrorHome = path.join(tempRoot, 'home-api-error'); const apiErrorBin = path.join(tempRoot, 'bin-api-error'); const apiErrorClaudeConfig = path.join(tempRoot, 'claude-api-error'); + const invalidProxyHome = path.join(tempRoot, 'home-invalid-proxy'); const successHome = path.join(tempRoot, 'home-success'); const successBin = path.join(tempRoot, 'bin-success'); const successClaudeConfig = path.join(tempRoot, 'claude-success'); @@ -49,6 +52,7 @@ describe('fetchUsageData error handling', () => { fs.mkdirSync(apiErrorHome, { recursive: true }); fs.mkdirSync(apiErrorBin, { recursive: true }); fs.mkdirSync(apiErrorClaudeConfig, { recursive: true }); + fs.mkdirSync(invalidProxyHome, { recursive: true }); fs.mkdirSync(successHome, { recursive: true }); fs.mkdirSync(successBin, { recursive: true }); fs.mkdirSync(successClaudeConfig, { recursive: true }); @@ -71,10 +75,15 @@ const https = require('https'); const mode = process.env.TEST_REQUEST_MODE || 'success'; const responseBody = process.env.TEST_RESPONSE_BODY || ''; let requestCount = 0; +let proxyAgentConfigured = false; +let requestHost = null; https.request = (...args) => { requestCount += 1; const callback = args.find(value => typeof value === 'function'); + const options = args.find(value => value && typeof value === 'object' && !Buffer.isBuffer(value)); + proxyAgentConfigured = Boolean(options?.agent); + requestHost = options?.hostname ?? null; const requestHandlers = new Map(); const responseHandlers = new Map(); @@ -145,7 +154,9 @@ process.stdout.write(JSON.stringify({ second, lockExists: fs.existsSync(lockFile), cacheExists: fs.existsSync(cacheFile), - requestCount + requestCount, + proxyAgentConfigured, + requestHost })); `; @@ -169,6 +180,7 @@ process.stdout.write(JSON.stringify({ expect(noCredentialsResult.lockExists).toBe(false); expect(noCredentialsResult.cacheExists).toBe(false); expect(noCredentialsResult.requestCount).toBe(0); + expect(noCredentialsResult.proxyAgentConfigured).toBe(false); const apiErrorOutput = execFileSync(process.execPath, [probeScriptPath], { encoding: 'utf8', @@ -187,6 +199,8 @@ process.stdout.write(JSON.stringify({ expect(apiErrorResult.second).toEqual({ error: 'api-error' }); expect(apiErrorResult.cacheExists).toBe(false); expect(apiErrorResult.requestCount).toBe(1); + expect(apiErrorResult.proxyAgentConfigured).toBe(false); + expect(apiErrorResult.requestHost).toBe('api.anthropic.com'); const successOutput = execFileSync(process.execPath, [probeScriptPath], { encoding: 'utf8', @@ -211,6 +225,109 @@ process.stdout.write(JSON.stringify({ expect(successResult.second).toEqual(successResult.first); expect(successResult.cacheExists).toBe(true); expect(successResult.requestCount).toBe(1); + expect(successResult.proxyAgentConfigured).toBe(false); + expect(successResult.requestHost).toBe('api.anthropic.com'); + + const httpsProxyOutput = execFileSync(process.execPath, [probeScriptPath], { + encoding: 'utf8', + env: { + ...process.env, + HOME: apiErrorHome, + PATH: apiErrorBin, + CLAUDE_CONFIG_DIR: apiErrorClaudeConfig, + HTTPS_PROXY: 'http://proxy.local:8080', + TEST_REQUEST_MODE: 'success', + TEST_RESPONSE_BODY: successResponseBody, + TEST_NOW_MS: '2200000000000' + } + }); + + const httpsProxyResult = JSON.parse(httpsProxyOutput) as UsageProbeResult; + expect(httpsProxyResult.first).toEqual(successResult.first); + expect(httpsProxyResult.second).toEqual(successResult.first); + expect(httpsProxyResult.requestCount).toBe(1); + expect(httpsProxyResult.proxyAgentConfigured).toBe(true); + expect(httpsProxyResult.requestHost).toBe('api.anthropic.com'); + + const lowercaseProxyOutput = execFileSync(process.execPath, [probeScriptPath], { + encoding: 'utf8', + env: { + ...process.env, + HOME: apiErrorHome, + PATH: apiErrorBin, + CLAUDE_CONFIG_DIR: apiErrorClaudeConfig, + https_proxy: 'http://proxy.local:8080', + TEST_REQUEST_MODE: 'success', + TEST_RESPONSE_BODY: successResponseBody, + TEST_NOW_MS: '2200000000000' + } + }); + + const lowercaseProxyResult = JSON.parse(lowercaseProxyOutput) as UsageProbeResult; + expect(lowercaseProxyResult.first).toEqual(successResult.first); + expect(lowercaseProxyResult.second).toEqual(successResult.first); + expect(lowercaseProxyResult.requestCount).toBe(1); + expect(lowercaseProxyResult.proxyAgentConfigured).toBe(false); + + const blankProxyOutput = execFileSync(process.execPath, [probeScriptPath], { + encoding: 'utf8', + env: { + ...process.env, + HOME: apiErrorHome, + PATH: apiErrorBin, + CLAUDE_CONFIG_DIR: apiErrorClaudeConfig, + HTTPS_PROXY: ' ', + TEST_REQUEST_MODE: 'success', + TEST_RESPONSE_BODY: successResponseBody, + TEST_NOW_MS: '2200000000000' + } + }); + + const blankProxyResult = JSON.parse(blankProxyOutput) as UsageProbeResult; + expect(blankProxyResult.first).toEqual(successResult.first); + expect(blankProxyResult.second).toEqual(successResult.first); + expect(blankProxyResult.requestCount).toBe(1); + expect(blankProxyResult.proxyAgentConfigured).toBe(false); + + const invalidProxyOutput = execFileSync(process.execPath, [probeScriptPath], { + encoding: 'utf8', + env: { + ...process.env, + HOME: invalidProxyHome, + PATH: apiErrorBin, + CLAUDE_CONFIG_DIR: apiErrorClaudeConfig, + HTTPS_PROXY: '://bad-proxy', + TEST_REQUEST_MODE: 'success', + TEST_RESPONSE_BODY: successResponseBody, + TEST_NOW_MS: '2200000000000' + } + }); + + const invalidProxyResult = JSON.parse(invalidProxyOutput) as UsageProbeResult; + expect(invalidProxyResult.first).toEqual({ error: 'api-error' }); + expect(invalidProxyResult.second).toEqual({ error: 'api-error' }); + expect(invalidProxyResult.requestCount).toBe(0); + expect(invalidProxyResult.proxyAgentConfigured).toBe(false); + + const staleProxyOutput = execFileSync(process.execPath, [probeScriptPath], { + encoding: 'utf8', + env: { + ...process.env, + HOME: successHome, + PATH: successBin, + CLAUDE_CONFIG_DIR: successClaudeConfig, + HTTPS_PROXY: '://bad-proxy', + TEST_REQUEST_MODE: 'success', + TEST_RESPONSE_BODY: successResponseBody, + TEST_NOW_MS: '2200000181000' + } + }); + + const staleProxyResult = JSON.parse(staleProxyOutput) as UsageProbeResult; + expect(staleProxyResult.first).toEqual(successResult.first); + expect(staleProxyResult.second).toEqual(successResult.first); + expect(staleProxyResult.requestCount).toBe(0); + expect(staleProxyResult.proxyAgentConfigured).toBe(false); const cachedSuccessOutput = execFileSync(process.execPath, [probeScriptPath], { encoding: 'utf8', diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index 678bf34..eee8087 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -1,6 +1,7 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as https from 'https'; +import { HttpsProxyAgent } from 'https-proxy-agent'; import * as os from 'os'; import * as path from 'path'; import { z } from 'zod'; @@ -174,6 +175,36 @@ function readStaleUsageCache(): UsageData | null { const USAGE_API_HOST = 'api.anthropic.com'; const USAGE_API_PATH = '/api/oauth/usage'; +const USAGE_API_TIMEOUT_MS = 5000; + +function getUsageApiProxyUrl(): string | null { + const proxyUrl = process.env.HTTPS_PROXY?.trim(); + if (proxyUrl === '') { + return null; + } + + return proxyUrl ?? null; +} + +function getUsageApiRequestOptions(token: string): https.RequestOptions | null { + const proxyUrl = getUsageApiProxyUrl(); + + try { + return { + hostname: USAGE_API_HOST, + path: USAGE_API_PATH, + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'anthropic-beta': 'oauth-2025-04-20' + }, + timeout: USAGE_API_TIMEOUT_MS, + ...(proxyUrl ? { agent: new HttpsProxyAgent(proxyUrl) } : {}) + }; + } catch { + return null; + } +} async function fetchFromUsageApi(token: string): Promise { return new Promise((resolve) => { @@ -187,16 +218,13 @@ async function fetchFromUsageApi(token: string): Promise { resolve(value); }; - const request = https.request({ - hostname: USAGE_API_HOST, - path: USAGE_API_PATH, - method: 'GET', - headers: { - 'Authorization': `Bearer ${token}`, - 'anthropic-beta': 'oauth-2025-04-20' - }, - timeout: 5000 - }, (response) => { + const requestOptions = getUsageApiRequestOptions(token); + if (!requestOptions) { + finish(null); + return; + } + + const request = https.request(requestOptions, (response) => { let data = ''; response.setEncoding('utf8');