Fix CRLF line endings, harden install.sh

- Add .gitattributes enforcing LF line endings
- Renormalize all files from CRLF to LF
- Replace fragile sed-based JSON manipulation with node -e
- Add Node.js 18+ version check (required for built-in fetch)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-02-14 15:28:34 +00:00
parent ddd3b6d637
commit d152dde002
5 changed files with 290 additions and 284 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View File

@@ -1,106 +1,106 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Claude Usage Fetcher (Standalone) * Claude Usage Fetcher (Standalone)
* *
* Fetches token usage from claude.ai API using a session key. * Fetches token usage from claude.ai API using a session key.
* Designed to run as a cron job on headless servers. * Designed to run as a cron job on headless servers.
* *
* Session key source (checked in order): * Session key source (checked in order):
* 1. CLAUDE_SESSION_KEY env var * 1. CLAUDE_SESSION_KEY env var
* 2. ~/.config/claude-statusline/session-key (plain text file) * 2. ~/.config/claude-statusline/session-key (plain text file)
* *
* To get your session key: * To get your session key:
* 1. Log into claude.ai in any browser * 1. Log into claude.ai in any browser
* 2. Open DevTools → Application → Cookies → claude.ai * 2. Open DevTools → Application → Cookies → claude.ai
* 3. Copy the value of the "sessionKey" cookie * 3. Copy the value of the "sessionKey" cookie
* 4. Save it: echo "sk-ant-..." > ~/.config/claude-statusline/session-key * 4. Save it: echo "sk-ant-..." > ~/.config/claude-statusline/session-key
* *
* Output: Writes JSON to $CLAUDE_USAGE_CACHE or /tmp/claude_usage.json * Output: Writes JSON to $CLAUDE_USAGE_CACHE or /tmp/claude_usage.json
* *
* Cron example (every 5 min): * Cron example (every 5 min):
* */5 * * * * /usr/bin/node /path/to/fetch-usage.js 2>/dev/null * */5 * * * * /usr/bin/node /path/to/fetch-usage.js 2>/dev/null
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// --- Config --- // --- Config ---
const CONFIG_DIR = process.env.CLAUDE_STATUSLINE_CONFIG const CONFIG_DIR = process.env.CLAUDE_STATUSLINE_CONFIG
|| path.join(process.env.HOME || '/root', '.config', 'claude-statusline'); || path.join(process.env.HOME || '/root', '.config', 'claude-statusline');
const CACHE_FILE = process.env.CLAUDE_USAGE_CACHE const CACHE_FILE = process.env.CLAUDE_USAGE_CACHE
|| path.join(process.env.TMPDIR || '/tmp', 'claude_usage.json'); || path.join(process.env.TMPDIR || '/tmp', 'claude_usage.json');
const ORG_ID = process.env.CLAUDE_ORG_ID || ''; const ORG_ID = process.env.CLAUDE_ORG_ID || '';
// --- Session Key --- // --- Session Key ---
function getSessionKey() { function getSessionKey() {
// env var first // env var first
if (process.env.CLAUDE_SESSION_KEY) return process.env.CLAUDE_SESSION_KEY.trim(); if (process.env.CLAUDE_SESSION_KEY) return process.env.CLAUDE_SESSION_KEY.trim();
// config file // config file
const keyFile = path.join(CONFIG_DIR, 'session-key'); const keyFile = path.join(CONFIG_DIR, 'session-key');
try { try {
return fs.readFileSync(keyFile, 'utf8').trim(); return fs.readFileSync(keyFile, 'utf8').trim();
} catch { } catch {
return null; return null;
} }
} }
// --- Discover org ID --- // --- Discover org ID ---
async function getOrgId(sessionKey) { async function getOrgId(sessionKey) {
if (ORG_ID) return ORG_ID; if (ORG_ID) return ORG_ID;
const resp = await fetch('https://claude.ai/api/organizations', { const resp = await fetch('https://claude.ai/api/organizations', {
headers: headers(sessionKey), headers: headers(sessionKey),
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
}); });
if (!resp.ok) throw new Error('Failed to list orgs: ' + resp.status); if (!resp.ok) throw new Error('Failed to list orgs: ' + resp.status);
const orgs = await resp.json(); const orgs = await resp.json();
if (!orgs.length) throw new Error('No organizations found'); if (!orgs.length) throw new Error('No organizations found');
return orgs[0].uuid; return orgs[0].uuid;
} }
function headers(sessionKey) { function headers(sessionKey) {
return { return {
'Cookie': 'sessionKey=' + sessionKey, 'Cookie': 'sessionKey=' + sessionKey,
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0', 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0',
'Accept': 'application/json', 'Accept': 'application/json',
'Referer': 'https://claude.ai/', 'Referer': 'https://claude.ai/',
'Origin': 'https://claude.ai', 'Origin': 'https://claude.ai',
}; };
} }
// --- Fetch --- // --- Fetch ---
async function main() { async function main() {
const sessionKey = getSessionKey(); const sessionKey = getSessionKey();
if (!sessionKey) { if (!sessionKey) {
console.error('No session key found. Set CLAUDE_SESSION_KEY or create ' + console.error('No session key found. Set CLAUDE_SESSION_KEY or create ' +
path.join(CONFIG_DIR, 'session-key')); path.join(CONFIG_DIR, 'session-key'));
process.exit(1); process.exit(1);
} }
try { try {
const orgId = await getOrgId(sessionKey); const orgId = await getOrgId(sessionKey);
const resp = await fetch('https://claude.ai/api/organizations/' + orgId + '/usage', { const resp = await fetch('https://claude.ai/api/organizations/' + orgId + '/usage', {
headers: headers(sessionKey), headers: headers(sessionKey),
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
}); });
if (!resp.ok) { if (!resp.ok) {
console.error('API error: ' + resp.status); console.error('API error: ' + resp.status);
process.exit(1); process.exit(1);
} }
const data = await resp.json(); const data = await resp.json();
// Ensure cache dir exists // Ensure cache dir exists
const cacheDir = path.dirname(CACHE_FILE); const cacheDir = path.dirname(CACHE_FILE);
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true }); if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2)); fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
console.log('OK — wrote ' + CACHE_FILE); console.log('OK — wrote ' + CACHE_FILE);
} catch (e) { } catch (e) {
console.error('Fetch error: ' + e.message); console.error('Fetch error: ' + e.message);
process.exit(1); process.exit(1);
} }
} }
main(); main();

View File

@@ -1,73 +1,78 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Install claude-statusline for the current user. # Install claude-statusline for the current user.
# Usage: bash install.sh # Usage: bash install.sh
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_DIR="${HOME}/.local/share/claude-statusline" INSTALL_DIR="${HOME}/.local/share/claude-statusline"
CONFIG_DIR="${HOME}/.config/claude-statusline" CONFIG_DIR="${HOME}/.config/claude-statusline"
CLAUDE_DIR="${HOME}/.claude" CLAUDE_DIR="${HOME}/.claude"
echo "==> Installing claude-statusline..." # --- Check Node.js version (need 18+ for built-in fetch) ---
NODE_BIN="$(which node 2>/dev/null || echo '')"
# Copy scripts if [ -z "$NODE_BIN" ]; then
mkdir -p "$INSTALL_DIR" echo "ERROR: Node.js not found. Install Node.js 18+ first."
cp "$SCRIPT_DIR/statusline.js" "$INSTALL_DIR/" exit 1
cp "$SCRIPT_DIR/fetch-usage.js" "$INSTALL_DIR/" fi
chmod +x "$INSTALL_DIR"/*.js NODE_MAJOR=$("$NODE_BIN" -e "process.stdout.write(String(process.versions.node.split('.')[0]))")
if [ "$NODE_MAJOR" -lt 18 ]; then
# Create config dir echo "ERROR: Node.js $("$NODE_BIN" --version) found, but 18+ is required (built-in fetch)."
mkdir -p "$CONFIG_DIR" exit 1
if [ ! -f "$CONFIG_DIR/session-key" ]; then fi
echo "# Paste your claude.ai sessionKey cookie value here" > "$CONFIG_DIR/session-key" echo "==> Node.js v${NODE_MAJOR} found at $NODE_BIN"
chmod 600 "$CONFIG_DIR/session-key"
echo " Created $CONFIG_DIR/session-key (edit with your session key)" echo "==> Installing claude-statusline..."
fi
# Copy scripts
# Configure Claude Code statusline mkdir -p "$INSTALL_DIR"
mkdir -p "$CLAUDE_DIR" cp "$SCRIPT_DIR/statusline.js" "$INSTALL_DIR/"
SETTINGS="$CLAUDE_DIR/settings.json" cp "$SCRIPT_DIR/fetch-usage.js" "$INSTALL_DIR/"
NODE_BIN="$(which node 2>/dev/null || echo '/usr/bin/node')" chmod +x "$INSTALL_DIR"/*.js
if [ -f "$SETTINGS" ]; then # Create config dir
# Check if statusLine already configured mkdir -p "$CONFIG_DIR"
if grep -q '"statusLine"' "$SETTINGS" 2>/dev/null; then if [ ! -f "$CONFIG_DIR/session-key" ]; then
echo " statusLine already configured in $SETTINGS — skipping" echo "# Paste your claude.ai sessionKey cookie value here" > "$CONFIG_DIR/session-key"
else chmod 600 "$CONFIG_DIR/session-key"
# Insert statusLine before last closing brace echo " Created $CONFIG_DIR/session-key (edit with your session key)"
TMP=$(mktemp) fi
sed '$ d' "$SETTINGS" > "$TMP"
# Add comma if needed # Configure Claude Code statusline
if grep -q '[^{]' "$TMP"; then mkdir -p "$CLAUDE_DIR"
echo ',' >> "$TMP" SETTINGS="$CLAUDE_DIR/settings.json"
fi
cat >> "$TMP" <<SEOF if [ -f "$SETTINGS" ]; then
"statusLine": { if grep -q '"statusLine"' "$SETTINGS" 2>/dev/null; then
"type": "command", echo " statusLine already configured in $SETTINGS — skipping"
"command": "${NODE_BIN} --no-warnings ${INSTALL_DIR}/statusline.js" else
} "$NODE_BIN" -e "
} const fs = require('fs');
SEOF const settings = JSON.parse(fs.readFileSync('$SETTINGS', 'utf8'));
mv "$TMP" "$SETTINGS" settings.statusLine = {
echo " Added statusLine to $SETTINGS" type: 'command',
fi command: '$NODE_BIN --no-warnings $INSTALL_DIR/statusline.js'
else };
cat > "$SETTINGS" <<SEOF fs.writeFileSync('$SETTINGS', JSON.stringify(settings, null, 2) + '\n');
{ "
"statusLine": { echo " Added statusLine to $SETTINGS"
"type": "command", fi
"command": "${NODE_BIN} --no-warnings ${INSTALL_DIR}/statusline.js" else
} cat > "$SETTINGS" <<SEOF
} {
SEOF "statusLine": {
echo " Created $SETTINGS with statusLine config" "type": "command",
fi "command": "${NODE_BIN} --no-warnings ${INSTALL_DIR}/statusline.js"
}
# Offer cron setup }
echo "" SEOF
echo "==> Optional: set up usage fetcher cron (every 5 min):" echo " Created $SETTINGS with statusLine config"
echo " crontab -e" fi
echo " */5 * * * * ${NODE_BIN} ${INSTALL_DIR}/fetch-usage.js 2>/dev/null"
echo "" # Offer cron setup
echo "==> Done! Restart Claude Code to see the statusline." echo ""
echo " Don't forget to add your session key to: $CONFIG_DIR/session-key" echo "==> 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"

View File

@@ -1,13 +1,13 @@
{ {
"name": "claude-statusline", "name": "claude-statusline",
"version": "0.1.0", "version": "0.1.0",
"description": "Standalone Claude Code statusline for headless Linux servers", "description": "Standalone Claude Code statusline for headless Linux servers",
"main": "statusline.js", "main": "statusline.js",
"scripts": { "scripts": {
"statusline": "node statusline.js", "statusline": "node statusline.js",
"fetch": "node fetch-usage.js", "fetch": "node fetch-usage.js",
"install-statusline": "bash install.sh" "install-statusline": "bash install.sh"
}, },
"keywords": ["claude", "statusline", "cli"], "keywords": ["claude", "statusline", "cli"],
"license": "MIT" "license": "MIT"
} }

View File

@@ -1,92 +1,92 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Claude Code Status Line (Standalone) * Claude Code Status Line (Standalone)
* *
* Designed for headless Linux servers — no browser, no tray app needed. * Designed for headless Linux servers — no browser, no tray app needed.
* *
* Context window: Always shown, read from stdin (JSON piped by Claude Code). * 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 * Token usage: Optional. Reads from a cache file that can be populated by
* the included fetcher (cron) or any external source. * the included fetcher (cron) or any external source.
* *
* Output: Context ▓▓▓▓░░░░░░ 40% | Token ░░░░░░░░░░ 10% 267M * Output: Context ▓▓▓▓░░░░░░ 40% | Token ░░░░░░░░░░ 10% 267M
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// --- Config --- // --- Config ---
const CACHE_FILE = process.env.CLAUDE_USAGE_CACHE const CACHE_FILE = process.env.CLAUDE_USAGE_CACHE
|| path.join(process.env.TMPDIR || process.env.TEMP || '/tmp', 'claude_usage.json'); || 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 const CACHE_MAX_AGE_S = parseInt(process.env.CLAUDE_USAGE_MAX_AGE || '900', 10); // 15 min default
// --- Bar renderer --- // --- Bar renderer ---
function bar(pct, width) { function bar(pct, width) {
width = width || 10; width = width || 10;
const filled = Math.floor(Math.min(100, Math.max(0, pct)) / (100 / width)); const filled = Math.floor(Math.min(100, Math.max(0, pct)) / (100 / width));
return '\u2593'.repeat(filled) + '\u2591'.repeat(width - filled); return '\u2593'.repeat(filled) + '\u2591'.repeat(width - filled);
} }
// --- Context Window --- // --- Context Window ---
function getContextPart(data) { function getContextPart(data) {
try { try {
const pct = Math.round(parseFloat(data?.context_window?.used_percentage) || 0); const pct = Math.round(parseFloat(data?.context_window?.used_percentage) || 0);
return 'Context ' + bar(pct) + ' ' + pct + '%'; return 'Context ' + bar(pct) + ' ' + pct + '%';
} catch { } catch {
return 'Context ' + '\u2591'.repeat(10); return 'Context ' + '\u2591'.repeat(10);
} }
} }
// --- Usage Cache --- // --- Usage Cache ---
function readCache() { function readCache() {
try { try {
if (!fs.existsSync(CACHE_FILE)) return null; if (!fs.existsSync(CACHE_FILE)) return null;
const stat = fs.statSync(CACHE_FILE); const stat = fs.statSync(CACHE_FILE);
const ageS = (Date.now() - stat.mtimeMs) / 1000; const ageS = (Date.now() - stat.mtimeMs) / 1000;
if (ageS > CACHE_MAX_AGE_S) return null; if (ageS > CACHE_MAX_AGE_S) return null;
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
} catch { } catch {
return null; return null;
} }
} }
function formatMinutes(isoStr) { function formatMinutes(isoStr) {
if (!isoStr) return ''; if (!isoStr) return '';
try { try {
const remaining = Math.max(0, Math.round((new Date(isoStr).getTime() - Date.now()) / 60000)); const remaining = Math.max(0, Math.round((new Date(isoStr).getTime() - Date.now()) / 60000));
return remaining + 'M'; return remaining + 'M';
} catch { } catch {
return ''; return '';
} }
} }
function getUsagePart(data) { function getUsagePart(data) {
if (!data) return ''; if (!data) return '';
const parts = []; const parts = [];
if (data.five_hour && data.five_hour.utilization > 0) { if (data.five_hour && data.five_hour.utilization > 0) {
const pct = Math.round(data.five_hour.utilization); const pct = Math.round(data.five_hour.utilization);
const remaining = formatMinutes(data.five_hour.resets_at); const remaining = formatMinutes(data.five_hour.resets_at);
parts.push('Token ' + bar(pct) + ' ' + pct + '%' + (remaining ? ' ' + remaining : '')); parts.push('Token ' + bar(pct) + ' ' + pct + '%' + (remaining ? ' ' + remaining : ''));
} }
if (data.seven_day && data.seven_day.utilization > 20) { if (data.seven_day && data.seven_day.utilization > 20) {
const pct = Math.round(data.seven_day.utilization); const pct = Math.round(data.seven_day.utilization);
parts.push('7d ' + pct + '%'); parts.push('7d ' + pct + '%');
} }
return parts.join(' | '); return parts.join(' | ');
} }
// --- Main --- // --- Main ---
function main() { function main() {
let stdinData = {}; let stdinData = {};
try { try {
stdinData = JSON.parse(fs.readFileSync(0, 'utf8')); stdinData = JSON.parse(fs.readFileSync(0, 'utf8'));
} catch { /* no stdin or invalid JSON */ } } catch { /* no stdin or invalid JSON */ }
const ctx = getContextPart(stdinData); const ctx = getContextPart(stdinData);
const usage = getUsagePart(readCache()); const usage = getUsagePart(readCache());
console.log(usage ? ctx + ' | ' + usage : ctx); console.log(usage ? ctx + ' | ' + usage : ctx);
} }
main(); main();