diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/fetch-usage.js b/fetch-usage.js index d153c50..423b974 100755 --- a/fetch-usage.js +++ b/fetch-usage.js @@ -1,106 +1,106 @@ -#!/usr/bin/env node -/** - * Claude Usage Fetcher (Standalone) - * - * Fetches token usage from claude.ai API using a session key. - * Designed to run as a cron job on headless servers. - * - * Session key source (checked in order): - * 1. CLAUDE_SESSION_KEY env var - * 2. ~/.config/claude-statusline/session-key (plain text file) - * - * To get your session key: - * 1. Log into claude.ai in any browser - * 2. Open DevTools → Application → Cookies → claude.ai - * 3. Copy the value of the "sessionKey" cookie - * 4. Save it: echo "sk-ant-..." > ~/.config/claude-statusline/session-key - * - * Output: Writes JSON to $CLAUDE_USAGE_CACHE or /tmp/claude_usage.json - * - * Cron example (every 5 min): - * */5 * * * * /usr/bin/node /path/to/fetch-usage.js 2>/dev/null - */ -const fs = require('fs'); -const path = require('path'); - -// --- Config --- -const CONFIG_DIR = process.env.CLAUDE_STATUSLINE_CONFIG - || path.join(process.env.HOME || '/root', '.config', 'claude-statusline'); -const CACHE_FILE = process.env.CLAUDE_USAGE_CACHE - || path.join(process.env.TMPDIR || '/tmp', 'claude_usage.json'); -const ORG_ID = process.env.CLAUDE_ORG_ID || ''; - -// --- Session Key --- -function getSessionKey() { - // env var first - if (process.env.CLAUDE_SESSION_KEY) return process.env.CLAUDE_SESSION_KEY.trim(); - - // config file - const keyFile = path.join(CONFIG_DIR, 'session-key'); - try { - return fs.readFileSync(keyFile, 'utf8').trim(); - } catch { - return null; - } -} - -// --- Discover org ID --- -async function getOrgId(sessionKey) { - if (ORG_ID) return ORG_ID; - - const resp = await fetch('https://claude.ai/api/organizations', { - headers: headers(sessionKey), - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) throw new Error('Failed to list orgs: ' + resp.status); - const orgs = await resp.json(); - if (!orgs.length) throw new Error('No organizations found'); - return orgs[0].uuid; -} - -function headers(sessionKey) { - return { - 'Cookie': 'sessionKey=' + sessionKey, - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0', - 'Accept': 'application/json', - 'Referer': 'https://claude.ai/', - 'Origin': 'https://claude.ai', - }; -} - -// --- Fetch --- -async function main() { - const sessionKey = getSessionKey(); - if (!sessionKey) { - console.error('No session key found. Set CLAUDE_SESSION_KEY or create ' + - path.join(CONFIG_DIR, 'session-key')); - process.exit(1); - } - - try { - const orgId = await getOrgId(sessionKey); - const resp = await fetch('https://claude.ai/api/organizations/' + orgId + '/usage', { - headers: headers(sessionKey), - signal: AbortSignal.timeout(10000), - }); - - if (!resp.ok) { - console.error('API error: ' + resp.status); - process.exit(1); - } - - const data = await resp.json(); - - // Ensure cache dir exists - const cacheDir = path.dirname(CACHE_FILE); - if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true }); - - fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2)); - console.log('OK — wrote ' + CACHE_FILE); - } catch (e) { - console.error('Fetch error: ' + e.message); - process.exit(1); - } -} - -main(); +#!/usr/bin/env node +/** + * Claude Usage Fetcher (Standalone) + * + * Fetches token usage from claude.ai API using a session key. + * Designed to run as a cron job on headless servers. + * + * Session key source (checked in order): + * 1. CLAUDE_SESSION_KEY env var + * 2. ~/.config/claude-statusline/session-key (plain text file) + * + * To get your session key: + * 1. Log into claude.ai in any browser + * 2. Open DevTools → Application → Cookies → claude.ai + * 3. Copy the value of the "sessionKey" cookie + * 4. Save it: echo "sk-ant-..." > ~/.config/claude-statusline/session-key + * + * Output: Writes JSON to $CLAUDE_USAGE_CACHE or /tmp/claude_usage.json + * + * Cron example (every 5 min): + * */5 * * * * /usr/bin/node /path/to/fetch-usage.js 2>/dev/null + */ +const fs = require('fs'); +const path = require('path'); + +// --- Config --- +const CONFIG_DIR = process.env.CLAUDE_STATUSLINE_CONFIG + || path.join(process.env.HOME || '/root', '.config', 'claude-statusline'); +const CACHE_FILE = process.env.CLAUDE_USAGE_CACHE + || path.join(process.env.TMPDIR || '/tmp', 'claude_usage.json'); +const ORG_ID = process.env.CLAUDE_ORG_ID || ''; + +// --- Session Key --- +function getSessionKey() { + // env var first + if (process.env.CLAUDE_SESSION_KEY) return process.env.CLAUDE_SESSION_KEY.trim(); + + // config file + const keyFile = path.join(CONFIG_DIR, 'session-key'); + try { + return fs.readFileSync(keyFile, 'utf8').trim(); + } catch { + return null; + } +} + +// --- Discover org ID --- +async function getOrgId(sessionKey) { + if (ORG_ID) return ORG_ID; + + const resp = await fetch('https://claude.ai/api/organizations', { + headers: headers(sessionKey), + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) throw new Error('Failed to list orgs: ' + resp.status); + const orgs = await resp.json(); + if (!orgs.length) throw new Error('No organizations found'); + return orgs[0].uuid; +} + +function headers(sessionKey) { + return { + 'Cookie': 'sessionKey=' + sessionKey, + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0', + 'Accept': 'application/json', + 'Referer': 'https://claude.ai/', + 'Origin': 'https://claude.ai', + }; +} + +// --- Fetch --- +async function main() { + const sessionKey = getSessionKey(); + if (!sessionKey) { + console.error('No session key found. Set CLAUDE_SESSION_KEY or create ' + + path.join(CONFIG_DIR, 'session-key')); + process.exit(1); + } + + try { + const orgId = await getOrgId(sessionKey); + const resp = await fetch('https://claude.ai/api/organizations/' + orgId + '/usage', { + headers: headers(sessionKey), + signal: AbortSignal.timeout(10000), + }); + + if (!resp.ok) { + console.error('API error: ' + resp.status); + process.exit(1); + } + + const data = await resp.json(); + + // Ensure cache dir exists + const cacheDir = path.dirname(CACHE_FILE); + if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true }); + + fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2)); + console.log('OK — wrote ' + CACHE_FILE); + } catch (e) { + console.error('Fetch error: ' + e.message); + process.exit(1); + } +} + +main(); diff --git a/install.sh b/install.sh index 090e4a5..117c84d 100755 --- a/install.sh +++ b/install.sh @@ -1,73 +1,78 @@ -#!/usr/bin/env bash -# Install claude-statusline for the current user. -# Usage: bash install.sh -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -INSTALL_DIR="${HOME}/.local/share/claude-statusline" -CONFIG_DIR="${HOME}/.config/claude-statusline" -CLAUDE_DIR="${HOME}/.claude" - -echo "==> Installing claude-statusline..." - -# Copy scripts -mkdir -p "$INSTALL_DIR" -cp "$SCRIPT_DIR/statusline.js" "$INSTALL_DIR/" -cp "$SCRIPT_DIR/fetch-usage.js" "$INSTALL_DIR/" -chmod +x "$INSTALL_DIR"/*.js - -# Create config dir -mkdir -p "$CONFIG_DIR" -if [ ! -f "$CONFIG_DIR/session-key" ]; then - echo "# Paste your claude.ai sessionKey cookie value here" > "$CONFIG_DIR/session-key" - chmod 600 "$CONFIG_DIR/session-key" - echo " Created $CONFIG_DIR/session-key (edit with your session key)" -fi - -# Configure Claude Code statusline -mkdir -p "$CLAUDE_DIR" -SETTINGS="$CLAUDE_DIR/settings.json" -NODE_BIN="$(which node 2>/dev/null || echo '/usr/bin/node')" - -if [ -f "$SETTINGS" ]; then - # Check if statusLine already configured - if grep -q '"statusLine"' "$SETTINGS" 2>/dev/null; then - echo " statusLine already configured in $SETTINGS — skipping" - else - # Insert statusLine before last closing brace - TMP=$(mktemp) - sed '$ d' "$SETTINGS" > "$TMP" - # Add comma if needed - if grep -q '[^{]' "$TMP"; then - echo ',' >> "$TMP" - fi - cat >> "$TMP" < "$SETTINGS" < Optional: set up usage fetcher cron (every 5 min):" -echo " crontab -e" -echo " */5 * * * * ${NODE_BIN} ${INSTALL_DIR}/fetch-usage.js 2>/dev/null" -echo "" -echo "==> Done! Restart Claude Code to see the statusline." -echo " Don't forget to add your session key to: $CONFIG_DIR/session-key" +#!/usr/bin/env bash +# Install claude-statusline for the current user. +# Usage: bash install.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_DIR="${HOME}/.local/share/claude-statusline" +CONFIG_DIR="${HOME}/.config/claude-statusline" +CLAUDE_DIR="${HOME}/.claude" + +# --- Check Node.js version (need 18+ for built-in fetch) --- +NODE_BIN="$(which node 2>/dev/null || echo '')" +if [ -z "$NODE_BIN" ]; then + echo "ERROR: Node.js not found. Install Node.js 18+ first." + exit 1 +fi +NODE_MAJOR=$("$NODE_BIN" -e "process.stdout.write(String(process.versions.node.split('.')[0]))") +if [ "$NODE_MAJOR" -lt 18 ]; then + echo "ERROR: Node.js $("$NODE_BIN" --version) found, but 18+ is required (built-in fetch)." + exit 1 +fi +echo "==> Node.js v${NODE_MAJOR} found at $NODE_BIN" + +echo "==> Installing claude-statusline..." + +# Copy scripts +mkdir -p "$INSTALL_DIR" +cp "$SCRIPT_DIR/statusline.js" "$INSTALL_DIR/" +cp "$SCRIPT_DIR/fetch-usage.js" "$INSTALL_DIR/" +chmod +x "$INSTALL_DIR"/*.js + +# Create config dir +mkdir -p "$CONFIG_DIR" +if [ ! -f "$CONFIG_DIR/session-key" ]; then + echo "# Paste your claude.ai sessionKey cookie value here" > "$CONFIG_DIR/session-key" + chmod 600 "$CONFIG_DIR/session-key" + echo " Created $CONFIG_DIR/session-key (edit with your session key)" +fi + +# Configure Claude Code statusline +mkdir -p "$CLAUDE_DIR" +SETTINGS="$CLAUDE_DIR/settings.json" + +if [ -f "$SETTINGS" ]; then + if grep -q '"statusLine"' "$SETTINGS" 2>/dev/null; then + echo " statusLine already configured in $SETTINGS — skipping" + else + "$NODE_BIN" -e " +const fs = require('fs'); +const settings = JSON.parse(fs.readFileSync('$SETTINGS', 'utf8')); +settings.statusLine = { + type: 'command', + command: '$NODE_BIN --no-warnings $INSTALL_DIR/statusline.js' +}; +fs.writeFileSync('$SETTINGS', JSON.stringify(settings, null, 2) + '\n'); +" + echo " Added statusLine to $SETTINGS" + fi +else + cat > "$SETTINGS" < Optional: set up usage fetcher cron (every 5 min):" +echo " crontab -e" +echo " */5 * * * * ${NODE_BIN} ${INSTALL_DIR}/fetch-usage.js 2>/dev/null" +echo "" +echo "==> Done! Restart Claude Code to see the statusline." +echo " Don't forget to add your session key to: $CONFIG_DIR/session-key" diff --git a/package.json b/package.json index c22b07f..fc2490f 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ -{ - "name": "claude-statusline", - "version": "0.1.0", - "description": "Standalone Claude Code statusline for headless Linux servers", - "main": "statusline.js", - "scripts": { - "statusline": "node statusline.js", - "fetch": "node fetch-usage.js", - "install-statusline": "bash install.sh" - }, - "keywords": ["claude", "statusline", "cli"], - "license": "MIT" -} +{ + "name": "claude-statusline", + "version": "0.1.0", + "description": "Standalone Claude Code statusline for headless Linux servers", + "main": "statusline.js", + "scripts": { + "statusline": "node statusline.js", + "fetch": "node fetch-usage.js", + "install-statusline": "bash install.sh" + }, + "keywords": ["claude", "statusline", "cli"], + "license": "MIT" +} diff --git a/statusline.js b/statusline.js index 984b887..2c889f5 100755 --- a/statusline.js +++ b/statusline.js @@ -1,92 +1,92 @@ -#!/usr/bin/env node -/** - * Claude Code Status Line (Standalone) - * - * Designed for headless Linux servers — no browser, no tray app needed. - * - * Context window: Always shown, read from stdin (JSON piped by Claude Code). - * Token usage: Optional. Reads from a cache file that can be populated by - * the included fetcher (cron) or any external source. - * - * Output: Context ▓▓▓▓░░░░░░ 40% | Token ░░░░░░░░░░ 10% 267M - */ -const fs = require('fs'); -const path = require('path'); - -// --- Config --- -const CACHE_FILE = process.env.CLAUDE_USAGE_CACHE - || path.join(process.env.TMPDIR || process.env.TEMP || '/tmp', 'claude_usage.json'); -const CACHE_MAX_AGE_S = parseInt(process.env.CLAUDE_USAGE_MAX_AGE || '900', 10); // 15 min default - -// --- Bar renderer --- -function bar(pct, width) { - width = width || 10; - const filled = Math.floor(Math.min(100, Math.max(0, pct)) / (100 / width)); - return '\u2593'.repeat(filled) + '\u2591'.repeat(width - filled); -} - -// --- Context Window --- -function getContextPart(data) { - try { - const pct = Math.round(parseFloat(data?.context_window?.used_percentage) || 0); - return 'Context ' + bar(pct) + ' ' + pct + '%'; - } catch { - return 'Context ' + '\u2591'.repeat(10); - } -} - -// --- Usage Cache --- -function readCache() { - try { - if (!fs.existsSync(CACHE_FILE)) return null; - const stat = fs.statSync(CACHE_FILE); - const ageS = (Date.now() - stat.mtimeMs) / 1000; - if (ageS > CACHE_MAX_AGE_S) return null; - return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); - } catch { - return null; - } -} - -function formatMinutes(isoStr) { - if (!isoStr) return ''; - try { - const remaining = Math.max(0, Math.round((new Date(isoStr).getTime() - Date.now()) / 60000)); - return remaining + 'M'; - } catch { - return ''; - } -} - -function getUsagePart(data) { - if (!data) return ''; - const parts = []; - - if (data.five_hour && data.five_hour.utilization > 0) { - const pct = Math.round(data.five_hour.utilization); - const remaining = formatMinutes(data.five_hour.resets_at); - parts.push('Token ' + bar(pct) + ' ' + pct + '%' + (remaining ? ' ' + remaining : '')); - } - - if (data.seven_day && data.seven_day.utilization > 20) { - const pct = Math.round(data.seven_day.utilization); - parts.push('7d ' + pct + '%'); - } - - return parts.join(' | '); -} - -// --- Main --- -function main() { - let stdinData = {}; - try { - stdinData = JSON.parse(fs.readFileSync(0, 'utf8')); - } catch { /* no stdin or invalid JSON */ } - - const ctx = getContextPart(stdinData); - const usage = getUsagePart(readCache()); - - console.log(usage ? ctx + ' | ' + usage : ctx); -} - -main(); +#!/usr/bin/env node +/** + * Claude Code Status Line (Standalone) + * + * Designed for headless Linux servers — no browser, no tray app needed. + * + * Context window: Always shown, read from stdin (JSON piped by Claude Code). + * Token usage: Optional. Reads from a cache file that can be populated by + * the included fetcher (cron) or any external source. + * + * Output: Context ▓▓▓▓░░░░░░ 40% | Token ░░░░░░░░░░ 10% 267M + */ +const fs = require('fs'); +const path = require('path'); + +// --- Config --- +const CACHE_FILE = process.env.CLAUDE_USAGE_CACHE + || path.join(process.env.TMPDIR || process.env.TEMP || '/tmp', 'claude_usage.json'); +const CACHE_MAX_AGE_S = parseInt(process.env.CLAUDE_USAGE_MAX_AGE || '900', 10); // 15 min default + +// --- Bar renderer --- +function bar(pct, width) { + width = width || 10; + const filled = Math.floor(Math.min(100, Math.max(0, pct)) / (100 / width)); + return '\u2593'.repeat(filled) + '\u2591'.repeat(width - filled); +} + +// --- Context Window --- +function getContextPart(data) { + try { + const pct = Math.round(parseFloat(data?.context_window?.used_percentage) || 0); + return 'Context ' + bar(pct) + ' ' + pct + '%'; + } catch { + return 'Context ' + '\u2591'.repeat(10); + } +} + +// --- Usage Cache --- +function readCache() { + try { + if (!fs.existsSync(CACHE_FILE)) return null; + const stat = fs.statSync(CACHE_FILE); + const ageS = (Date.now() - stat.mtimeMs) / 1000; + if (ageS > CACHE_MAX_AGE_S) return null; + return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); + } catch { + return null; + } +} + +function formatMinutes(isoStr) { + if (!isoStr) return ''; + try { + const remaining = Math.max(0, Math.round((new Date(isoStr).getTime() - Date.now()) / 60000)); + return remaining + 'M'; + } catch { + return ''; + } +} + +function getUsagePart(data) { + if (!data) return ''; + const parts = []; + + if (data.five_hour && data.five_hour.utilization > 0) { + const pct = Math.round(data.five_hour.utilization); + const remaining = formatMinutes(data.five_hour.resets_at); + parts.push('Token ' + bar(pct) + ' ' + pct + '%' + (remaining ? ' ' + remaining : '')); + } + + if (data.seven_day && data.seven_day.utilization > 20) { + const pct = Math.round(data.seven_day.utilization); + parts.push('7d ' + pct + '%'); + } + + return parts.join(' | '); +} + +// --- Main --- +function main() { + let stdinData = {}; + try { + stdinData = JSON.parse(fs.readFileSync(0, 'utf8')); + } catch { /* no stdin or invalid JSON */ } + + const ctx = getContextPart(stdinData); + const usage = getUsagePart(readCache()); + + console.log(usage ? ctx + ' | ' + usage : ctx); +} + +main();