Reorganize: Move all skills to skills/ folder

- Created skills/ directory
- Moved 272 skills to skills/ subfolder
- Kept agents/ at root level
- Kept installation scripts and docs at root level

Repository structure:
- skills/           - All 272 skills from skills.sh
- agents/           - Agent definitions
- *.sh, *.ps1       - Installation scripts
- README.md, etc.   - Documentation

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-23 18:05:17 +00:00
Unverified
parent 2b4e974878
commit b723e2bd7d
4083 changed files with 1056 additions and 1098063 deletions

View File

@@ -0,0 +1,197 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createDebug } from './debug.js';
const debug = createDebug('config');
export interface ConfigCounts {
claudeMdCount: number;
rulesCount: number;
mcpCount: number;
hooksCount: number;
}
// Valid keys for disabled MCP arrays in config files
type DisabledMcpKey = 'disabledMcpServers' | 'disabledMcpjsonServers';
function getMcpServerNames(filePath: string): Set<string> {
if (!fs.existsSync(filePath)) return new Set();
try {
const content = fs.readFileSync(filePath, 'utf8');
const config = JSON.parse(content);
if (config.mcpServers && typeof config.mcpServers === 'object') {
return new Set(Object.keys(config.mcpServers));
}
} catch (error) {
debug(`Failed to read MCP servers from ${filePath}:`, error);
}
return new Set();
}
function getDisabledMcpServers(filePath: string, key: DisabledMcpKey): Set<string> {
if (!fs.existsSync(filePath)) return new Set();
try {
const content = fs.readFileSync(filePath, 'utf8');
const config = JSON.parse(content);
if (Array.isArray(config[key])) {
const validNames = config[key].filter((s: unknown) => typeof s === 'string');
if (validNames.length !== config[key].length) {
debug(`${key} in ${filePath} contains non-string values, ignoring them`);
}
return new Set(validNames);
}
} catch (error) {
debug(`Failed to read ${key} from ${filePath}:`, error);
}
return new Set();
}
function countMcpServersInFile(filePath: string, excludeFrom?: string): number {
const servers = getMcpServerNames(filePath);
if (excludeFrom) {
const exclude = getMcpServerNames(excludeFrom);
for (const name of exclude) {
servers.delete(name);
}
}
return servers.size;
}
function countHooksInFile(filePath: string): number {
if (!fs.existsSync(filePath)) return 0;
try {
const content = fs.readFileSync(filePath, 'utf8');
const config = JSON.parse(content);
if (config.hooks && typeof config.hooks === 'object') {
return Object.keys(config.hooks).length;
}
} catch (error) {
debug(`Failed to read hooks from ${filePath}:`, error);
}
return 0;
}
function countRulesInDir(rulesDir: string): number {
if (!fs.existsSync(rulesDir)) return 0;
let count = 0;
try {
const entries = fs.readdirSync(rulesDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(rulesDir, entry.name);
if (entry.isDirectory()) {
count += countRulesInDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
count++;
}
}
} catch (error) {
debug(`Failed to read rules from ${rulesDir}:`, error);
}
return count;
}
export async function countConfigs(cwd?: string): Promise<ConfigCounts> {
let claudeMdCount = 0;
let rulesCount = 0;
let hooksCount = 0;
const homeDir = os.homedir();
const claudeDir = path.join(homeDir, '.claude');
// Collect all MCP servers across scopes, then subtract disabled ones
const userMcpServers = new Set<string>();
const projectMcpServers = new Set<string>();
// === USER SCOPE ===
// ~/.claude/CLAUDE.md
if (fs.existsSync(path.join(claudeDir, 'CLAUDE.md'))) {
claudeMdCount++;
}
// ~/.claude/rules/*.md
rulesCount += countRulesInDir(path.join(claudeDir, 'rules'));
// ~/.claude/settings.json (MCPs and hooks)
const userSettings = path.join(claudeDir, 'settings.json');
for (const name of getMcpServerNames(userSettings)) {
userMcpServers.add(name);
}
hooksCount += countHooksInFile(userSettings);
// ~/.claude.json (additional user-scope MCPs)
const userClaudeJson = path.join(homeDir, '.claude.json');
for (const name of getMcpServerNames(userClaudeJson)) {
userMcpServers.add(name);
}
// Get disabled user-scope MCPs from ~/.claude.json
const disabledUserMcps = getDisabledMcpServers(userClaudeJson, 'disabledMcpServers');
for (const name of disabledUserMcps) {
userMcpServers.delete(name);
}
// === PROJECT SCOPE ===
if (cwd) {
// {cwd}/CLAUDE.md
if (fs.existsSync(path.join(cwd, 'CLAUDE.md'))) {
claudeMdCount++;
}
// {cwd}/CLAUDE.local.md
if (fs.existsSync(path.join(cwd, 'CLAUDE.local.md'))) {
claudeMdCount++;
}
// {cwd}/.claude/CLAUDE.md (alternative location)
if (fs.existsSync(path.join(cwd, '.claude', 'CLAUDE.md'))) {
claudeMdCount++;
}
// {cwd}/.claude/CLAUDE.local.md
if (fs.existsSync(path.join(cwd, '.claude', 'CLAUDE.local.md'))) {
claudeMdCount++;
}
// {cwd}/.claude/rules/*.md (recursive)
rulesCount += countRulesInDir(path.join(cwd, '.claude', 'rules'));
// {cwd}/.mcp.json (project MCP config) - tracked separately for disabled filtering
const mcpJsonServers = getMcpServerNames(path.join(cwd, '.mcp.json'));
// {cwd}/.claude/settings.json (project settings)
const projectSettings = path.join(cwd, '.claude', 'settings.json');
for (const name of getMcpServerNames(projectSettings)) {
projectMcpServers.add(name);
}
hooksCount += countHooksInFile(projectSettings);
// {cwd}/.claude/settings.local.json (local project settings)
const localSettings = path.join(cwd, '.claude', 'settings.local.json');
for (const name of getMcpServerNames(localSettings)) {
projectMcpServers.add(name);
}
hooksCount += countHooksInFile(localSettings);
// Get disabled .mcp.json servers from settings.local.json
const disabledMcpJsonServers = getDisabledMcpServers(localSettings, 'disabledMcpjsonServers');
for (const name of disabledMcpJsonServers) {
mcpJsonServers.delete(name);
}
// Add remaining .mcp.json servers to project set
for (const name of mcpJsonServers) {
projectMcpServers.add(name);
}
}
// Total MCP count = user servers + project servers
// Note: Deduplication only occurs within each scope, not across scopes.
// A server with the same name in both user and project scope counts as 2 (separate configs).
const mcpCount = userMcpServers.size + projectMcpServers.size;
return { claudeMdCount, rulesCount, mcpCount, hooksCount };
}

View File

@@ -0,0 +1,186 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
export type LineLayoutType = 'compact' | 'expanded';
export type AutocompactBufferMode = 'enabled' | 'disabled';
export interface HudConfig {
lineLayout: LineLayoutType;
showSeparators: boolean;
pathLevels: 1 | 2 | 3;
gitStatus: {
enabled: boolean;
showDirty: boolean;
showAheadBehind: boolean;
showFileStats: boolean;
};
display: {
showModel: boolean;
showContextBar: boolean;
showConfigCounts: boolean;
showDuration: boolean;
showTokenBreakdown: boolean;
showUsage: boolean;
showTools: boolean;
showAgents: boolean;
showTodos: boolean;
autocompactBuffer: AutocompactBufferMode;
usageThreshold: number;
environmentThreshold: number;
};
}
export const DEFAULT_CONFIG: HudConfig = {
lineLayout: 'expanded',
showSeparators: false,
pathLevels: 1,
gitStatus: {
enabled: true,
showDirty: true,
showAheadBehind: false,
showFileStats: false,
},
display: {
showModel: true,
showContextBar: true,
showConfigCounts: true,
showDuration: true,
showTokenBreakdown: true,
showUsage: true,
showTools: true,
showAgents: true,
showTodos: true,
autocompactBuffer: 'enabled',
usageThreshold: 0,
environmentThreshold: 0,
},
};
export function getConfigPath(): string {
const homeDir = os.homedir();
return path.join(homeDir, '.claude', 'plugins', 'claude-hud', 'config.json');
}
function validatePathLevels(value: unknown): value is 1 | 2 | 3 {
return value === 1 || value === 2 || value === 3;
}
function validateLineLayout(value: unknown): value is LineLayoutType {
return value === 'compact' || value === 'expanded';
}
function validateAutocompactBuffer(value: unknown): value is AutocompactBufferMode {
return value === 'enabled' || value === 'disabled';
}
interface LegacyConfig {
layout?: 'default' | 'separators';
}
function migrateConfig(userConfig: Partial<HudConfig> & LegacyConfig): Partial<HudConfig> {
const migrated = { ...userConfig } as Partial<HudConfig> & LegacyConfig;
if ('layout' in userConfig && !('lineLayout' in userConfig)) {
if (userConfig.layout === 'separators') {
migrated.lineLayout = 'compact';
migrated.showSeparators = true;
} else {
migrated.lineLayout = 'compact';
migrated.showSeparators = false;
}
delete migrated.layout;
}
return migrated;
}
function validateThreshold(value: unknown, max = 100): number {
if (typeof value !== 'number') return 0;
return Math.max(0, Math.min(max, value));
}
function mergeConfig(userConfig: Partial<HudConfig>): HudConfig {
const migrated = migrateConfig(userConfig);
const lineLayout = validateLineLayout(migrated.lineLayout)
? migrated.lineLayout
: DEFAULT_CONFIG.lineLayout;
const showSeparators = typeof migrated.showSeparators === 'boolean'
? migrated.showSeparators
: DEFAULT_CONFIG.showSeparators;
const pathLevels = validatePathLevels(migrated.pathLevels)
? migrated.pathLevels
: DEFAULT_CONFIG.pathLevels;
const gitStatus = {
enabled: typeof migrated.gitStatus?.enabled === 'boolean'
? migrated.gitStatus.enabled
: DEFAULT_CONFIG.gitStatus.enabled,
showDirty: typeof migrated.gitStatus?.showDirty === 'boolean'
? migrated.gitStatus.showDirty
: DEFAULT_CONFIG.gitStatus.showDirty,
showAheadBehind: typeof migrated.gitStatus?.showAheadBehind === 'boolean'
? migrated.gitStatus.showAheadBehind
: DEFAULT_CONFIG.gitStatus.showAheadBehind,
showFileStats: typeof migrated.gitStatus?.showFileStats === 'boolean'
? migrated.gitStatus.showFileStats
: DEFAULT_CONFIG.gitStatus.showFileStats,
};
const display = {
showModel: typeof migrated.display?.showModel === 'boolean'
? migrated.display.showModel
: DEFAULT_CONFIG.display.showModel,
showContextBar: typeof migrated.display?.showContextBar === 'boolean'
? migrated.display.showContextBar
: DEFAULT_CONFIG.display.showContextBar,
showConfigCounts: typeof migrated.display?.showConfigCounts === 'boolean'
? migrated.display.showConfigCounts
: DEFAULT_CONFIG.display.showConfigCounts,
showDuration: typeof migrated.display?.showDuration === 'boolean'
? migrated.display.showDuration
: DEFAULT_CONFIG.display.showDuration,
showTokenBreakdown: typeof migrated.display?.showTokenBreakdown === 'boolean'
? migrated.display.showTokenBreakdown
: DEFAULT_CONFIG.display.showTokenBreakdown,
showUsage: typeof migrated.display?.showUsage === 'boolean'
? migrated.display.showUsage
: DEFAULT_CONFIG.display.showUsage,
showTools: typeof migrated.display?.showTools === 'boolean'
? migrated.display.showTools
: DEFAULT_CONFIG.display.showTools,
showAgents: typeof migrated.display?.showAgents === 'boolean'
? migrated.display.showAgents
: DEFAULT_CONFIG.display.showAgents,
showTodos: typeof migrated.display?.showTodos === 'boolean'
? migrated.display.showTodos
: DEFAULT_CONFIG.display.showTodos,
autocompactBuffer: validateAutocompactBuffer(migrated.display?.autocompactBuffer)
? migrated.display.autocompactBuffer
: DEFAULT_CONFIG.display.autocompactBuffer,
usageThreshold: validateThreshold(migrated.display?.usageThreshold, 100),
environmentThreshold: validateThreshold(migrated.display?.environmentThreshold, 100),
};
return { lineLayout, showSeparators, pathLevels, gitStatus, display };
}
export async function loadConfig(): Promise<HudConfig> {
const configPath = getConfigPath();
try {
if (!fs.existsSync(configPath)) {
return DEFAULT_CONFIG;
}
const content = fs.readFileSync(configPath, 'utf-8');
const userConfig = JSON.parse(content) as Partial<HudConfig>;
return mergeConfig(userConfig);
} catch {
return DEFAULT_CONFIG;
}
}

View File

@@ -0,0 +1,9 @@
/**
* Autocompact buffer percentage.
*
* NOTE: This value (22.5% = 45k/200k) is empirically derived from community
* observations of Claude Code's autocompact behavior. It is NOT officially
* documented by Anthropic and may change in future Claude Code versions.
* If users report mismatches, this value may need adjustment.
*/
export const AUTOCOMPACT_BUFFER_PERCENT = 0.225;

View File

@@ -0,0 +1,16 @@
// Shared debug logging utility
// Enable via: DEBUG=claude-hud or DEBUG=*
const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*';
/**
* Create a namespaced debug logger
* @param namespace - Tag for log messages (e.g., 'config', 'usage')
*/
export function createDebug(namespace: string) {
return function debug(msg: string, ...args: unknown[]): void {
if (DEBUG) {
console.error(`[claude-hud:${namespace}] ${msg}`, ...args);
}
};
}

View File

@@ -0,0 +1,118 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export interface FileStats {
modified: number;
added: number;
deleted: number;
untracked: number;
}
export interface GitStatus {
branch: string;
isDirty: boolean;
ahead: number;
behind: number;
fileStats?: FileStats;
}
export async function getGitBranch(cwd?: string): Promise<string | null> {
if (!cwd) return null;
try {
const { stdout } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd, timeout: 1000, encoding: 'utf8' }
);
return stdout.trim() || null;
} catch {
return null;
}
}
export async function getGitStatus(cwd?: string): Promise<GitStatus | null> {
if (!cwd) return null;
try {
// Get branch name
const { stdout: branchOut } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd, timeout: 1000, encoding: 'utf8' }
);
const branch = branchOut.trim();
if (!branch) return null;
// Check for dirty state and parse file stats
let isDirty = false;
let fileStats: FileStats | undefined;
try {
const { stdout: statusOut } = await execFileAsync(
'git',
['--no-optional-locks', 'status', '--porcelain'],
{ cwd, timeout: 1000, encoding: 'utf8' }
);
const trimmed = statusOut.trim();
isDirty = trimmed.length > 0;
if (isDirty) {
fileStats = parseFileStats(trimmed);
}
} catch {
// Ignore errors, assume clean
}
// Get ahead/behind counts
let ahead = 0;
let behind = 0;
try {
const { stdout: revOut } = await execFileAsync(
'git',
['rev-list', '--left-right', '--count', '@{upstream}...HEAD'],
{ cwd, timeout: 1000, encoding: 'utf8' }
);
const parts = revOut.trim().split(/\s+/);
if (parts.length === 2) {
behind = parseInt(parts[0], 10) || 0;
ahead = parseInt(parts[1], 10) || 0;
}
} catch {
// No upstream or error, keep 0/0
}
return { branch, isDirty, ahead, behind, fileStats };
} catch {
return null;
}
}
/**
* Parse git status --porcelain output and count file stats (Starship-compatible format)
* Status codes: M=modified, A=added, D=deleted, ??=untracked
*/
function parseFileStats(porcelainOutput: string): FileStats {
const stats: FileStats = { modified: 0, added: 0, deleted: 0, untracked: 0 };
const lines = porcelainOutput.split('\n').filter(Boolean);
for (const line of lines) {
if (line.length < 2) continue;
const index = line[0]; // staged status
const worktree = line[1]; // unstaged status
if (line.startsWith('??')) {
stats.untracked++;
} else if (index === 'A') {
stats.added++;
} else if (index === 'D' || worktree === 'D') {
stats.deleted++;
} else if (index === 'M' || worktree === 'M' || index === 'R' || index === 'C') {
// M=modified, R=renamed (counts as modified), C=copied (counts as modified)
stats.modified++;
}
}
return stats;
}

View File

@@ -0,0 +1,99 @@
import { readStdin } from './stdin.js';
import { parseTranscript } from './transcript.js';
import { render } from './render/index.js';
import { countConfigs } from './config-reader.js';
import { getGitStatus } from './git.js';
import { getUsage } from './usage-api.js';
import { loadConfig } from './config.js';
import type { RenderContext } from './types.js';
import { fileURLToPath } from 'node:url';
export type MainDeps = {
readStdin: typeof readStdin;
parseTranscript: typeof parseTranscript;
countConfigs: typeof countConfigs;
getGitStatus: typeof getGitStatus;
getUsage: typeof getUsage;
loadConfig: typeof loadConfig;
render: typeof render;
now: () => number;
log: (...args: unknown[]) => void;
};
export async function main(overrides: Partial<MainDeps> = {}): Promise<void> {
const deps: MainDeps = {
readStdin,
parseTranscript,
countConfigs,
getGitStatus,
getUsage,
loadConfig,
render,
now: () => Date.now(),
log: console.log,
...overrides,
};
try {
const stdin = await deps.readStdin();
if (!stdin) {
deps.log('[claude-hud] Initializing...');
return;
}
const transcriptPath = stdin.transcript_path ?? '';
const transcript = await deps.parseTranscript(transcriptPath);
const { claudeMdCount, rulesCount, mcpCount, hooksCount } = await deps.countConfigs(stdin.cwd);
const config = await deps.loadConfig();
const gitStatus = config.gitStatus.enabled
? await deps.getGitStatus(stdin.cwd)
: null;
// Only fetch usage if enabled in config (replaces env var requirement)
const usageData = config.display.showUsage !== false
? await deps.getUsage()
: null;
const sessionDuration = formatSessionDuration(transcript.sessionStart, deps.now);
const ctx: RenderContext = {
stdin,
transcript,
claudeMdCount,
rulesCount,
mcpCount,
hooksCount,
sessionDuration,
gitStatus,
usageData,
config,
};
deps.render(ctx);
} catch (error) {
deps.log('[claude-hud] Error:', error instanceof Error ? error.message : 'Unknown error');
}
}
export function formatSessionDuration(sessionStart?: Date, now: () => number = () => Date.now()): string {
if (!sessionStart) {
return '';
}
const ms = now() - sessionStart.getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return '<1m';
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
const remainingMins = mins % 60;
return `${hours}h ${remainingMins}m`;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
void main();
}

View File

@@ -0,0 +1,54 @@
import type { RenderContext, AgentEntry } from '../types.js';
import { yellow, green, magenta, dim } from './colors.js';
export function renderAgentsLine(ctx: RenderContext): string | null {
const { agents } = ctx.transcript;
const runningAgents = agents.filter((a) => a.status === 'running');
const recentCompleted = agents
.filter((a) => a.status === 'completed')
.slice(-2);
const toShow = [...runningAgents, ...recentCompleted].slice(-3);
if (toShow.length === 0) {
return null;
}
const lines: string[] = [];
for (const agent of toShow) {
lines.push(formatAgent(agent));
}
return lines.join('\n');
}
function formatAgent(agent: AgentEntry): string {
const statusIcon = agent.status === 'running' ? yellow('◐') : green('✓');
const type = magenta(agent.type);
const model = agent.model ? dim(`[${agent.model}]`) : '';
const desc = agent.description ? dim(`: ${truncateDesc(agent.description)}`) : '';
const elapsed = formatElapsed(agent);
return `${statusIcon} ${type}${model ? ` ${model}` : ''}${desc} ${dim(`(${elapsed})`)}`;
}
function truncateDesc(desc: string, maxLen: number = 40): string {
if (desc.length <= maxLen) return desc;
return desc.slice(0, maxLen - 3) + '...';
}
function formatElapsed(agent: AgentEntry): string {
const now = Date.now();
const start = agent.startTime.getTime();
const end = agent.endTime?.getTime() ?? now;
const ms = end - start;
if (ms < 1000) return '<1s';
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
const mins = Math.floor(ms / 60000);
const secs = Math.round((ms % 60000) / 1000);
return `${mins}m ${secs}s`;
}

View File

@@ -0,0 +1,45 @@
export const RESET = '\x1b[0m';
const DIM = '\x1b[2m';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const MAGENTA = '\x1b[35m';
const CYAN = '\x1b[36m';
export function green(text: string): string {
return `${GREEN}${text}${RESET}`;
}
export function yellow(text: string): string {
return `${YELLOW}${text}${RESET}`;
}
export function red(text: string): string {
return `${RED}${text}${RESET}`;
}
export function cyan(text: string): string {
return `${CYAN}${text}${RESET}`;
}
export function magenta(text: string): string {
return `${MAGENTA}${text}${RESET}`;
}
export function dim(text: string): string {
return `${DIM}${text}${RESET}`;
}
export function getContextColor(percent: number): string {
if (percent >= 85) return RED;
if (percent >= 70) return YELLOW;
return GREEN;
}
export function coloredBar(percent: number, width: number = 10): string {
const filled = Math.round((percent / 100) * width);
const empty = width - filled;
const color = getContextColor(percent);
return `${color}${'█'.repeat(filled)}${DIM}${'░'.repeat(empty)}${RESET}`;
}

View File

@@ -0,0 +1,111 @@
import type { RenderContext } from '../types.js';
import { renderSessionLine } from './session-line.js';
import { renderToolsLine } from './tools-line.js';
import { renderAgentsLine } from './agents-line.js';
import { renderTodosLine } from './todos-line.js';
import {
renderIdentityLine,
renderProjectLine,
renderEnvironmentLine,
renderUsageLine,
} from './lines/index.js';
import { dim, RESET } from './colors.js';
function visualLength(str: string): number {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '').length;
}
function makeSeparator(length: number): string {
return dim('─'.repeat(Math.max(length, 20)));
}
function collectActivityLines(ctx: RenderContext): string[] {
const activityLines: string[] = [];
const display = ctx.config?.display;
if (display?.showTools !== false) {
const toolsLine = renderToolsLine(ctx);
if (toolsLine) {
activityLines.push(toolsLine);
}
}
if (display?.showAgents !== false) {
const agentsLine = renderAgentsLine(ctx);
if (agentsLine) {
activityLines.push(agentsLine);
}
}
if (display?.showTodos !== false) {
const todosLine = renderTodosLine(ctx);
if (todosLine) {
activityLines.push(todosLine);
}
}
return activityLines;
}
function renderCompact(ctx: RenderContext): string[] {
const lines: string[] = [];
const sessionLine = renderSessionLine(ctx);
if (sessionLine) {
lines.push(sessionLine);
}
return lines;
}
function renderExpanded(ctx: RenderContext): string[] {
const lines: string[] = [];
const identityLine = renderIdentityLine(ctx);
if (identityLine) {
lines.push(identityLine);
}
const projectLine = renderProjectLine(ctx);
if (projectLine) {
lines.push(projectLine);
}
const environmentLine = renderEnvironmentLine(ctx);
if (environmentLine) {
lines.push(environmentLine);
}
const usageLine = renderUsageLine(ctx);
if (usageLine) {
lines.push(usageLine);
}
return lines;
}
export function render(ctx: RenderContext): void {
const lineLayout = ctx.config?.lineLayout ?? 'expanded';
const showSeparators = ctx.config?.showSeparators ?? false;
const headerLines = lineLayout === 'expanded'
? renderExpanded(ctx)
: renderCompact(ctx);
const activityLines = collectActivityLines(ctx);
const lines: string[] = [...headerLines];
if (showSeparators && activityLines.length > 0) {
const maxWidth = Math.max(...headerLines.map(visualLength), 20);
lines.push(makeSeparator(maxWidth));
}
lines.push(...activityLines);
for (const line of lines) {
const outputLine = `${RESET}${line.replace(/ /g, '\u00A0')}`;
console.log(outputLine);
}
}

View File

@@ -0,0 +1,41 @@
import type { RenderContext } from '../../types.js';
import { dim } from '../colors.js';
export function renderEnvironmentLine(ctx: RenderContext): string | null {
const display = ctx.config?.display;
if (display?.showConfigCounts === false) {
return null;
}
const totalCounts = ctx.claudeMdCount + ctx.rulesCount + ctx.mcpCount + ctx.hooksCount;
const threshold = display?.environmentThreshold ?? 0;
if (totalCounts === 0 || totalCounts < threshold) {
return null;
}
const parts: string[] = [];
if (ctx.claudeMdCount > 0) {
parts.push(`${ctx.claudeMdCount} CLAUDE.md`);
}
if (ctx.rulesCount > 0) {
parts.push(`${ctx.rulesCount} rules`);
}
if (ctx.mcpCount > 0) {
parts.push(`${ctx.mcpCount} MCPs`);
}
if (ctx.hooksCount > 0) {
parts.push(`${ctx.hooksCount} hooks`);
}
if (parts.length === 0) {
return null;
}
return dim(parts.join(' | '));
}

View File

@@ -0,0 +1,62 @@
import type { RenderContext } from '../../types.js';
import { getContextPercent, getBufferedPercent, getModelName } from '../../stdin.js';
import { coloredBar, cyan, dim, getContextColor, RESET } from '../colors.js';
const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*';
export function renderIdentityLine(ctx: RenderContext): string {
const model = getModelName(ctx.stdin);
const rawPercent = getContextPercent(ctx.stdin);
const bufferedPercent = getBufferedPercent(ctx.stdin);
const autocompactMode = ctx.config?.display?.autocompactBuffer ?? 'enabled';
const percent = autocompactMode === 'disabled' ? rawPercent : bufferedPercent;
if (DEBUG && autocompactMode === 'disabled') {
console.error(`[claude-hud:context] autocompactBuffer=disabled, showing raw ${rawPercent}% (buffered would be ${bufferedPercent}%)`);
}
const bar = coloredBar(percent);
const display = ctx.config?.display;
const parts: string[] = [];
const planName = display?.showUsage !== false ? ctx.usageData?.planName : undefined;
const modelDisplay = planName ? `${model} | ${planName}` : model;
if (display?.showModel !== false && display?.showContextBar !== false) {
parts.push(`${cyan(`[${modelDisplay}]`)} ${bar} ${getContextColor(percent)}${percent}%${RESET}`);
} else if (display?.showModel !== false) {
parts.push(`${cyan(`[${modelDisplay}]`)} ${getContextColor(percent)}${percent}%${RESET}`);
} else if (display?.showContextBar !== false) {
parts.push(`${bar} ${getContextColor(percent)}${percent}%${RESET}`);
} else {
parts.push(`${getContextColor(percent)}${percent}%${RESET}`);
}
if (display?.showDuration !== false && ctx.sessionDuration) {
parts.push(dim(`⏱️ ${ctx.sessionDuration}`));
}
let line = parts.join(' | ');
if (display?.showTokenBreakdown !== false && percent >= 85) {
const usage = ctx.stdin.context_window?.current_usage;
if (usage) {
const input = formatTokens(usage.input_tokens ?? 0);
const cache = formatTokens((usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0));
line += dim(` (in: ${input}, cache: ${cache})`);
}
}
return line;
}
function formatTokens(n: number): string {
if (n >= 1000000) {
return `${(n / 1000000).toFixed(1)}M`;
}
if (n >= 1000) {
return `${(n / 1000).toFixed(0)}k`;
}
return n.toString();
}

View File

@@ -0,0 +1,4 @@
export { renderIdentityLine } from './identity.js';
export { renderProjectLine } from './project.js';
export { renderEnvironmentLine } from './environment.js';
export { renderUsageLine } from './usage.js';

View File

@@ -0,0 +1,49 @@
import type { RenderContext } from '../../types.js';
import { cyan, magenta, yellow } from '../colors.js';
export function renderProjectLine(ctx: RenderContext): string | null {
if (!ctx.stdin.cwd) {
return null;
}
const segments = ctx.stdin.cwd.split(/[/\\]/).filter(Boolean);
const pathLevels = ctx.config?.pathLevels ?? 1;
const projectPath = segments.length > 0 ? segments.slice(-pathLevels).join('/') : '/';
let gitPart = '';
const gitConfig = ctx.config?.gitStatus;
const showGit = gitConfig?.enabled ?? true;
if (showGit && ctx.gitStatus) {
const gitParts: string[] = [ctx.gitStatus.branch];
if ((gitConfig?.showDirty ?? true) && ctx.gitStatus.isDirty) {
gitParts.push('*');
}
if (gitConfig?.showAheadBehind) {
if (ctx.gitStatus.ahead > 0) {
gitParts.push(`${ctx.gitStatus.ahead}`);
}
if (ctx.gitStatus.behind > 0) {
gitParts.push(`${ctx.gitStatus.behind}`);
}
}
if (gitConfig?.showFileStats && ctx.gitStatus.fileStats) {
const { modified, added, deleted, untracked } = ctx.gitStatus.fileStats;
const statParts: string[] = [];
if (modified > 0) statParts.push(`!${modified}`);
if (added > 0) statParts.push(`+${added}`);
if (deleted > 0) statParts.push(`${deleted}`);
if (untracked > 0) statParts.push(`?${untracked}`);
if (statParts.length > 0) {
gitParts.push(` ${statParts.join(' ')}`);
}
}
gitPart = ` ${magenta('git:(')}${cyan(gitParts.join(''))}${magenta(')')}`;
}
return `${yellow(projectPath)}${gitPart}`;
}

View File

@@ -0,0 +1,70 @@
import type { RenderContext } from '../../types.js';
import { isLimitReached } from '../../types.js';
import { red, yellow, dim, getContextColor, RESET } from '../colors.js';
export function renderUsageLine(ctx: RenderContext): string | null {
const display = ctx.config?.display;
if (display?.showUsage === false) {
return null;
}
if (!ctx.usageData?.planName) {
return null;
}
if (ctx.usageData.apiUnavailable) {
return yellow(`usage: ⚠`);
}
if (isLimitReached(ctx.usageData)) {
const resetTime = ctx.usageData.fiveHour === 100
? formatResetTime(ctx.usageData.fiveHourResetAt)
: formatResetTime(ctx.usageData.sevenDayResetAt);
return red(`⚠ Limit reached${resetTime ? ` (resets ${resetTime})` : ''}`);
}
const threshold = display?.usageThreshold ?? 0;
const fiveHour = ctx.usageData.fiveHour;
const sevenDay = ctx.usageData.sevenDay;
const effectiveUsage = Math.max(fiveHour ?? 0, sevenDay ?? 0);
if (effectiveUsage < threshold) {
return null;
}
const fiveHourDisplay = formatUsagePercent(ctx.usageData.fiveHour);
const fiveHourReset = formatResetTime(ctx.usageData.fiveHourResetAt);
const fiveHourPart = fiveHourReset
? `5h: ${fiveHourDisplay} (${fiveHourReset})`
: `5h: ${fiveHourDisplay}`;
if (sevenDay !== null && sevenDay >= 80) {
const sevenDayDisplay = formatUsagePercent(sevenDay);
return `${fiveHourPart} | 7d: ${sevenDayDisplay}`;
}
return fiveHourPart;
}
function formatUsagePercent(percent: number | null): string {
if (percent === null) {
return dim('--');
}
const color = getContextColor(percent);
return `${color}${percent}%${RESET}`;
}
function formatResetTime(resetAt: Date | null): string {
if (!resetAt) return '';
const now = new Date();
const diffMs = resetAt.getTime() - now.getTime();
if (diffMs <= 0) return '';
const diffMins = Math.ceil(diffMs / 60000);
if (diffMins < 60) return `${diffMins}m`;
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}

View File

@@ -0,0 +1,201 @@
import type { RenderContext } from '../types.js';
import { isLimitReached } from '../types.js';
import { getContextPercent, getBufferedPercent, getModelName } from '../stdin.js';
import { coloredBar, cyan, dim, magenta, red, yellow, getContextColor, RESET } from './colors.js';
const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*';
/**
* Renders the full session line (model + context bar + project + git + counts + usage + duration).
* Used for compact layout mode.
*/
export function renderSessionLine(ctx: RenderContext): string {
const model = getModelName(ctx.stdin);
const rawPercent = getContextPercent(ctx.stdin);
const bufferedPercent = getBufferedPercent(ctx.stdin);
const autocompactMode = ctx.config?.display?.autocompactBuffer ?? 'enabled';
const percent = autocompactMode === 'disabled' ? rawPercent : bufferedPercent;
if (DEBUG && autocompactMode === 'disabled') {
console.error(`[claude-hud:context] autocompactBuffer=disabled, showing raw ${rawPercent}% (buffered would be ${bufferedPercent}%)`);
}
const bar = coloredBar(percent);
const parts: string[] = [];
const display = ctx.config?.display;
// Model and context bar (FIRST)
// Plan name only shows if showUsage is enabled (respects hybrid toggle)
const planName = display?.showUsage !== false ? ctx.usageData?.planName : undefined;
const modelDisplay = planName ? `${model} | ${planName}` : model;
if (display?.showModel !== false && display?.showContextBar !== false) {
parts.push(`${cyan(`[${modelDisplay}]`)} ${bar} ${getContextColor(percent)}${percent}%${RESET}`);
} else if (display?.showModel !== false) {
parts.push(`${cyan(`[${modelDisplay}]`)} ${getContextColor(percent)}${percent}%${RESET}`);
} else if (display?.showContextBar !== false) {
parts.push(`${bar} ${getContextColor(percent)}${percent}%${RESET}`);
} else {
parts.push(`${getContextColor(percent)}${percent}%${RESET}`);
}
// Project path (SECOND)
if (ctx.stdin.cwd) {
// Split by both Unix (/) and Windows (\) separators for cross-platform support
const segments = ctx.stdin.cwd.split(/[/\\]/).filter(Boolean);
const pathLevels = ctx.config?.pathLevels ?? 1;
// Always join with forward slash for consistent display
// Handle root path (/) which results in empty segments
const projectPath = segments.length > 0 ? segments.slice(-pathLevels).join('/') : '/';
// Build git status string
let gitPart = '';
const gitConfig = ctx.config?.gitStatus;
const showGit = gitConfig?.enabled ?? true;
if (showGit && ctx.gitStatus) {
const gitParts: string[] = [ctx.gitStatus.branch];
// Show dirty indicator
if ((gitConfig?.showDirty ?? true) && ctx.gitStatus.isDirty) {
gitParts.push('*');
}
// Show ahead/behind (with space separator for readability)
if (gitConfig?.showAheadBehind) {
if (ctx.gitStatus.ahead > 0) {
gitParts.push(`${ctx.gitStatus.ahead}`);
}
if (ctx.gitStatus.behind > 0) {
gitParts.push(`${ctx.gitStatus.behind}`);
}
}
// Show file stats in Starship-compatible format (!modified +added ✘deleted ?untracked)
if (gitConfig?.showFileStats && ctx.gitStatus.fileStats) {
const { modified, added, deleted, untracked } = ctx.gitStatus.fileStats;
const statParts: string[] = [];
if (modified > 0) statParts.push(`!${modified}`);
if (added > 0) statParts.push(`+${added}`);
if (deleted > 0) statParts.push(`${deleted}`);
if (untracked > 0) statParts.push(`?${untracked}`);
if (statParts.length > 0) {
gitParts.push(` ${statParts.join(' ')}`);
}
}
gitPart = ` ${magenta('git:(')}${cyan(gitParts.join(''))}${magenta(')')}`;
}
parts.push(`${yellow(projectPath)}${gitPart}`);
}
// Config counts (respects environmentThreshold)
if (display?.showConfigCounts !== false) {
const totalCounts = ctx.claudeMdCount + ctx.rulesCount + ctx.mcpCount + ctx.hooksCount;
const envThreshold = display?.environmentThreshold ?? 0;
if (totalCounts > 0 && totalCounts >= envThreshold) {
if (ctx.claudeMdCount > 0) {
parts.push(dim(`${ctx.claudeMdCount} CLAUDE.md`));
}
if (ctx.rulesCount > 0) {
parts.push(dim(`${ctx.rulesCount} rules`));
}
if (ctx.mcpCount > 0) {
parts.push(dim(`${ctx.mcpCount} MCPs`));
}
if (ctx.hooksCount > 0) {
parts.push(dim(`${ctx.hooksCount} hooks`));
}
}
}
// Usage limits display (shown when enabled in config, respects usageThreshold)
if (display?.showUsage !== false && ctx.usageData?.planName) {
if (ctx.usageData.apiUnavailable) {
parts.push(yellow(`usage: ⚠`));
} else if (isLimitReached(ctx.usageData)) {
const resetTime = ctx.usageData.fiveHour === 100
? formatResetTime(ctx.usageData.fiveHourResetAt)
: formatResetTime(ctx.usageData.sevenDayResetAt);
parts.push(red(`⚠ Limit reached${resetTime ? ` (resets ${resetTime})` : ''}`));
} else {
const usageThreshold = display?.usageThreshold ?? 0;
const fiveHour = ctx.usageData.fiveHour;
const sevenDay = ctx.usageData.sevenDay;
const effectiveUsage = Math.max(fiveHour ?? 0, sevenDay ?? 0);
if (effectiveUsage >= usageThreshold) {
const fiveHourDisplay = formatUsagePercent(fiveHour);
const fiveHourReset = formatResetTime(ctx.usageData.fiveHourResetAt);
const fiveHourPart = fiveHourReset
? `5h: ${fiveHourDisplay} (${fiveHourReset})`
: `5h: ${fiveHourDisplay}`;
if (sevenDay !== null && sevenDay >= 80) {
const sevenDayDisplay = formatUsagePercent(sevenDay);
parts.push(`${fiveHourPart} | 7d: ${sevenDayDisplay}`);
} else {
parts.push(fiveHourPart);
}
}
}
}
// Session duration
if (display?.showDuration !== false && ctx.sessionDuration) {
parts.push(dim(`⏱️ ${ctx.sessionDuration}`));
}
let line = parts.join(' | ');
// Token breakdown at high context
if (display?.showTokenBreakdown !== false && percent >= 85) {
const usage = ctx.stdin.context_window?.current_usage;
if (usage) {
const input = formatTokens(usage.input_tokens ?? 0);
const cache = formatTokens((usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0));
line += dim(` (in: ${input}, cache: ${cache})`);
}
}
return line;
}
function formatTokens(n: number): string {
if (n >= 1000000) {
return `${(n / 1000000).toFixed(1)}M`;
}
if (n >= 1000) {
return `${(n / 1000).toFixed(0)}k`;
}
return n.toString();
}
function formatUsagePercent(percent: number | null): string {
if (percent === null) {
return dim('--');
}
const color = getContextColor(percent);
return `${color}${percent}%${RESET}`;
}
function formatResetTime(resetAt: Date | null): string {
if (!resetAt) return '';
const now = new Date();
const diffMs = resetAt.getTime() - now.getTime();
if (diffMs <= 0) return '';
const diffMins = Math.ceil(diffMs / 60000);
if (diffMins < 60) return `${diffMins}m`;
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}

View File

@@ -0,0 +1,31 @@
import type { RenderContext } from '../types.js';
import { yellow, green, dim } from './colors.js';
export function renderTodosLine(ctx: RenderContext): string | null {
const { todos } = ctx.transcript;
if (!todos || todos.length === 0) {
return null;
}
const inProgress = todos.find((t) => t.status === 'in_progress');
const completed = todos.filter((t) => t.status === 'completed').length;
const total = todos.length;
if (!inProgress) {
if (completed === total && total > 0) {
return `${green('✓')} All todos complete ${dim(`(${completed}/${total})`)}`;
}
return null;
}
const content = truncateContent(inProgress.content);
const progress = dim(`(${completed}/${total})`);
return `${yellow('▸')} ${content} ${progress}`;
}
function truncateContent(content: string, maxLen: number = 50): string {
if (content.length <= maxLen) return content;
return content.slice(0, maxLen - 3) + '...';
}

View File

@@ -0,0 +1,57 @@
import type { RenderContext } from '../types.js';
import { yellow, green, cyan, dim } from './colors.js';
export function renderToolsLine(ctx: RenderContext): string | null {
const { tools } = ctx.transcript;
if (tools.length === 0) {
return null;
}
const parts: string[] = [];
const runningTools = tools.filter((t) => t.status === 'running');
const completedTools = tools.filter((t) => t.status === 'completed' || t.status === 'error');
for (const tool of runningTools.slice(-2)) {
const target = tool.target ? truncatePath(tool.target) : '';
parts.push(`${yellow('◐')} ${cyan(tool.name)}${target ? dim(`: ${target}`) : ''}`);
}
const toolCounts = new Map<string, number>();
for (const tool of completedTools) {
const count = toolCounts.get(tool.name) ?? 0;
toolCounts.set(tool.name, count + 1);
}
const sortedTools = Array.from(toolCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 4);
for (const [name, count] of sortedTools) {
parts.push(`${green('✓')} ${name} ${dim(`×${count}`)}`);
}
if (parts.length === 0) {
return null;
}
return parts.join(' | ');
}
function truncatePath(path: string, maxLen: number = 20): string {
// Normalize Windows backslashes to forward slashes for consistent display
const normalizedPath = path.replace(/\\/g, '/');
if (normalizedPath.length <= maxLen) return normalizedPath;
// Split by forward slash (already normalized)
const parts = normalizedPath.split('/');
const filename = parts.pop() || normalizedPath;
if (filename.length >= maxLen) {
return filename.slice(0, maxLen - 3) + '...';
}
return '.../' + filename;
}

View File

@@ -0,0 +1,85 @@
import type { StdinData } from './types.js';
import { AUTOCOMPACT_BUFFER_PERCENT } from './constants.js';
export async function readStdin(): Promise<StdinData | null> {
if (process.stdin.isTTY) {
return null;
}
const chunks: string[] = [];
try {
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) {
chunks.push(chunk as string);
}
const raw = chunks.join('');
if (!raw.trim()) {
return null;
}
return JSON.parse(raw) as StdinData;
} catch {
return null;
}
}
function getTotalTokens(stdin: StdinData): number {
const usage = stdin.context_window?.current_usage;
return (
(usage?.input_tokens ?? 0) +
(usage?.cache_creation_input_tokens ?? 0) +
(usage?.cache_read_input_tokens ?? 0)
);
}
/**
* Get native percentage from Claude Code v2.1.6+ if available.
* Returns null if not available or invalid, triggering fallback to manual calculation.
*/
function getNativePercent(stdin: StdinData): number | null {
const nativePercent = stdin.context_window?.used_percentage;
if (typeof nativePercent === 'number' && !Number.isNaN(nativePercent)) {
return Math.min(100, Math.max(0, Math.round(nativePercent)));
}
return null;
}
export function getContextPercent(stdin: StdinData): number {
// Prefer native percentage (v2.1.6+) - accurate and matches /context
const native = getNativePercent(stdin);
if (native !== null) {
return native;
}
// Fallback: manual calculation without buffer
const size = stdin.context_window?.context_window_size;
if (!size || size <= 0) {
return 0;
}
const totalTokens = getTotalTokens(stdin);
return Math.min(100, Math.round((totalTokens / size) * 100));
}
export function getBufferedPercent(stdin: StdinData): number {
// Prefer native percentage (v2.1.6+) - accurate and matches /context
// Native percentage already accounts for context correctly, no buffer needed
const native = getNativePercent(stdin);
if (native !== null) {
return native;
}
// Fallback: manual calculation with buffer for older Claude Code versions
const size = stdin.context_window?.context_window_size;
if (!size || size <= 0) {
return 0;
}
const totalTokens = getTotalTokens(stdin);
const buffer = size * AUTOCOMPACT_BUFFER_PERCENT;
return Math.min(100, Math.round(((totalTokens + buffer) / size) * 100));
}
export function getModelName(stdin: StdinData): string {
return stdin.model?.display_name ?? stdin.model?.id ?? 'Unknown';
}

View File

@@ -0,0 +1,145 @@
import * as fs from 'fs';
import * as readline from 'readline';
import type { TranscriptData, ToolEntry, AgentEntry, TodoItem } from './types.js';
interface TranscriptLine {
timestamp?: string;
message?: {
content?: ContentBlock[];
};
}
interface ContentBlock {
type: string;
id?: string;
name?: string;
input?: Record<string, unknown>;
tool_use_id?: string;
is_error?: boolean;
}
export async function parseTranscript(transcriptPath: string): Promise<TranscriptData> {
const result: TranscriptData = {
tools: [],
agents: [],
todos: [],
};
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
return result;
}
const toolMap = new Map<string, ToolEntry>();
const agentMap = new Map<string, AgentEntry>();
let latestTodos: TodoItem[] = [];
try {
const fileStream = fs.createReadStream(transcriptPath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
for await (const line of rl) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as TranscriptLine;
processEntry(entry, toolMap, agentMap, latestTodos, result);
} catch {
// Skip malformed lines
}
}
} catch {
// Return partial results on error
}
result.tools = Array.from(toolMap.values()).slice(-20);
result.agents = Array.from(agentMap.values()).slice(-10);
result.todos = latestTodos;
return result;
}
function processEntry(
entry: TranscriptLine,
toolMap: Map<string, ToolEntry>,
agentMap: Map<string, AgentEntry>,
latestTodos: TodoItem[],
result: TranscriptData
): void {
const timestamp = entry.timestamp ? new Date(entry.timestamp) : new Date();
if (!result.sessionStart && entry.timestamp) {
result.sessionStart = timestamp;
}
const content = entry.message?.content;
if (!content || !Array.isArray(content)) return;
for (const block of content) {
if (block.type === 'tool_use' && block.id && block.name) {
const toolEntry: ToolEntry = {
id: block.id,
name: block.name,
target: extractTarget(block.name, block.input),
status: 'running',
startTime: timestamp,
};
if (block.name === 'Task') {
const input = block.input as Record<string, unknown>;
const agentEntry: AgentEntry = {
id: block.id,
type: (input?.subagent_type as string) ?? 'unknown',
model: (input?.model as string) ?? undefined,
description: (input?.description as string) ?? undefined,
status: 'running',
startTime: timestamp,
};
agentMap.set(block.id, agentEntry);
} else if (block.name === 'TodoWrite') {
const input = block.input as { todos?: TodoItem[] };
if (input?.todos && Array.isArray(input.todos)) {
latestTodos.length = 0;
latestTodos.push(...input.todos);
}
} else {
toolMap.set(block.id, toolEntry);
}
}
if (block.type === 'tool_result' && block.tool_use_id) {
const tool = toolMap.get(block.tool_use_id);
if (tool) {
tool.status = block.is_error ? 'error' : 'completed';
tool.endTime = timestamp;
}
const agent = agentMap.get(block.tool_use_id);
if (agent) {
agent.status = 'completed';
agent.endTime = timestamp;
}
}
}
}
function extractTarget(toolName: string, input?: Record<string, unknown>): string | undefined {
if (!input) return undefined;
switch (toolName) {
case 'Read':
case 'Write':
case 'Edit':
return (input.file_path as string) ?? (input.path as string);
case 'Glob':
return input.pattern as string;
case 'Grep':
return input.pattern as string;
case 'Bash':
const cmd = input.command as string;
return cmd?.slice(0, 30) + (cmd?.length > 30 ? '...' : '');
}
return undefined;
}

View File

@@ -0,0 +1,86 @@
import type { HudConfig } from './config.js';
import type { GitStatus } from './git.js';
export interface StdinData {
transcript_path?: string;
cwd?: string;
model?: {
id?: string;
display_name?: string;
};
context_window?: {
context_window_size?: number;
current_usage?: {
input_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
} | null;
// Native percentage fields (Claude Code v2.1.6+)
used_percentage?: number | null;
remaining_percentage?: number | null;
};
}
export interface ToolEntry {
id: string;
name: string;
target?: string;
status: 'running' | 'completed' | 'error';
startTime: Date;
endTime?: Date;
}
export interface AgentEntry {
id: string;
type: string;
model?: string;
description?: string;
status: 'running' | 'completed';
startTime: Date;
endTime?: Date;
}
export interface TodoItem {
content: string;
status: 'pending' | 'in_progress' | 'completed';
}
/** Usage window data from the OAuth API */
export interface UsageWindow {
utilization: number | null; // 0-100 percentage, null if unavailable
resetAt: Date | null;
}
export interface UsageData {
planName: string | null; // 'Max', 'Pro', or null for API users
fiveHour: number | null; // 0-100 percentage, null if unavailable
sevenDay: number | null; // 0-100 percentage, null if unavailable
fiveHourResetAt: Date | null;
sevenDayResetAt: Date | null;
apiUnavailable?: boolean; // true if API call failed (user should check DEBUG logs)
}
/** Check if usage limit is reached (either window at 100%) */
export function isLimitReached(data: UsageData): boolean {
return data.fiveHour === 100 || data.sevenDay === 100;
}
export interface TranscriptData {
tools: ToolEntry[];
agents: AgentEntry[];
todos: TodoItem[];
sessionStart?: Date;
}
export interface RenderContext {
stdin: StdinData;
transcript: TranscriptData;
claudeMdCount: number;
rulesCount: number;
mcpCount: number;
hooksCount: number;
sessionDuration: string;
gitStatus: GitStatus | null;
usageData: UsageData | null;
config: HudConfig;
}

View File

@@ -0,0 +1,448 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as https from 'https';
import { execFileSync } from 'child_process';
import type { UsageData } from './types.js';
import { createDebug } from './debug.js';
export type { UsageData } from './types.js';
const debug = createDebug('usage');
interface CredentialsFile {
claudeAiOauth?: {
accessToken?: string;
refreshToken?: string;
subscriptionType?: string;
rateLimitTier?: string;
expiresAt?: number; // Unix millisecond timestamp
scopes?: string[];
};
}
interface UsageApiResponse {
five_hour?: {
utilization?: number;
resets_at?: string;
};
seven_day?: {
utilization?: number;
resets_at?: string;
};
}
// File-based cache (HUD runs as new process each render, so in-memory cache won't persist)
const CACHE_TTL_MS = 60_000; // 60 seconds
const CACHE_FAILURE_TTL_MS = 15_000; // 15 seconds for failed requests
const KEYCHAIN_TIMEOUT_MS = 5000;
const KEYCHAIN_BACKOFF_MS = 60_000; // Backoff on keychain failures to avoid re-prompting
interface CacheFile {
data: UsageData;
timestamp: number;
}
function getCachePath(homeDir: string): string {
return path.join(homeDir, '.claude', 'plugins', 'claude-hud', '.usage-cache.json');
}
function readCache(homeDir: string, now: number): UsageData | null {
try {
const cachePath = getCachePath(homeDir);
if (!fs.existsSync(cachePath)) return null;
const content = fs.readFileSync(cachePath, 'utf8');
const cache: CacheFile = JSON.parse(content);
// Check TTL - use shorter TTL for failure results
const ttl = cache.data.apiUnavailable ? CACHE_FAILURE_TTL_MS : CACHE_TTL_MS;
if (now - cache.timestamp >= ttl) return null;
// JSON.stringify converts Date to ISO string, so we need to reconvert on read.
// new Date() handles both Date objects and ISO strings safely.
const data = cache.data;
if (data.fiveHourResetAt) {
data.fiveHourResetAt = new Date(data.fiveHourResetAt);
}
if (data.sevenDayResetAt) {
data.sevenDayResetAt = new Date(data.sevenDayResetAt);
}
return data;
} catch {
return null;
}
}
function writeCache(homeDir: string, data: UsageData, timestamp: number): void {
try {
const cachePath = getCachePath(homeDir);
const cacheDir = path.dirname(cachePath);
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
const cache: CacheFile = { data, timestamp };
fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8');
} catch {
// Ignore cache write failures
}
}
// Dependency injection for testing
export type UsageApiDeps = {
homeDir: () => string;
fetchApi: (accessToken: string) => Promise<UsageApiResponse | null>;
now: () => number;
readKeychain: (now: number, homeDir: string) => { accessToken: string; subscriptionType: string } | null;
};
const defaultDeps: UsageApiDeps = {
homeDir: () => os.homedir(),
fetchApi: fetchUsageApi,
now: () => Date.now(),
readKeychain: readKeychainCredentials,
};
/**
* Get OAuth usage data from Anthropic API.
* Returns null if user is an API user (no OAuth credentials) or credentials are expired.
* Returns { apiUnavailable: true, ... } if API call fails (to show warning in HUD).
*
* Uses file-based cache since HUD runs as a new process each render (~300ms).
* Cache TTL: 60s for success, 15s for failures.
*/
export async function getUsage(overrides: Partial<UsageApiDeps> = {}): Promise<UsageData | null> {
const deps = { ...defaultDeps, ...overrides };
const now = deps.now();
const homeDir = deps.homeDir();
// Check file-based cache first
const cached = readCache(homeDir, now);
if (cached) {
return cached;
}
try {
const credentials = readCredentials(homeDir, now, deps.readKeychain);
if (!credentials) {
return null;
}
const { accessToken, subscriptionType } = credentials;
// Determine plan name from subscriptionType
const planName = getPlanName(subscriptionType);
if (!planName) {
// API user, no usage limits to show
return null;
}
// Fetch usage from API
const apiResponse = await deps.fetchApi(accessToken);
if (!apiResponse) {
// API call failed, cache the failure to prevent retry storms
const failureResult: UsageData = {
planName,
fiveHour: null,
sevenDay: null,
fiveHourResetAt: null,
sevenDayResetAt: null,
apiUnavailable: true,
};
writeCache(homeDir, failureResult, now);
return failureResult;
}
// Parse response - API returns 0-100 percentage directly
// Clamp to 0-100 and handle NaN/Infinity
const fiveHour = parseUtilization(apiResponse.five_hour?.utilization);
const sevenDay = parseUtilization(apiResponse.seven_day?.utilization);
const fiveHourResetAt = parseDate(apiResponse.five_hour?.resets_at);
const sevenDayResetAt = parseDate(apiResponse.seven_day?.resets_at);
const result: UsageData = {
planName,
fiveHour,
sevenDay,
fiveHourResetAt,
sevenDayResetAt,
};
// Write to file cache
writeCache(homeDir, result, now);
return result;
} catch (error) {
debug('getUsage failed:', error);
return null;
}
}
/**
* Get path for keychain failure backoff cache.
* Separate from usage cache to track keychain-specific failures.
*/
function getKeychainBackoffPath(homeDir: string): string {
return path.join(homeDir, '.claude', 'plugins', 'claude-hud', '.keychain-backoff');
}
/**
* Check if we're in keychain backoff period (recent failure/timeout).
* Prevents re-prompting user on every render cycle.
*/
function isKeychainBackoff(homeDir: string, now: number): boolean {
try {
const backoffPath = getKeychainBackoffPath(homeDir);
if (!fs.existsSync(backoffPath)) return false;
const timestamp = parseInt(fs.readFileSync(backoffPath, 'utf8'), 10);
return now - timestamp < KEYCHAIN_BACKOFF_MS;
} catch {
return false;
}
}
/**
* Record keychain failure for backoff.
*/
function recordKeychainFailure(homeDir: string, now: number): void {
try {
const backoffPath = getKeychainBackoffPath(homeDir);
const dir = path.dirname(backoffPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(backoffPath, String(now), 'utf8');
} catch {
// Ignore write failures
}
}
/**
* Read credentials from macOS Keychain.
* Claude Code 2.x stores OAuth credentials in the macOS Keychain under "Claude Code-credentials".
* Returns null if not on macOS or credentials not found.
*
* Security: Uses execFileSync with absolute path to avoid shell injection and PATH hijacking.
*/
function readKeychainCredentials(now: number, homeDir: string): { accessToken: string; subscriptionType: string } | null {
// Only available on macOS
if (process.platform !== 'darwin') {
return null;
}
// Check backoff to avoid re-prompting on every render after a failure
if (isKeychainBackoff(homeDir, now)) {
debug('Keychain in backoff period, skipping');
return null;
}
try {
// Read from macOS Keychain using security command
// Security: Use execFileSync with absolute path and args array (no shell)
const keychainData = execFileSync(
'/usr/bin/security',
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: KEYCHAIN_TIMEOUT_MS }
).trim();
if (!keychainData) {
return null;
}
const data: CredentialsFile = JSON.parse(keychainData);
return parseCredentialsData(data, now);
} catch (error) {
// Security: Only log error message, not full error object (may contain stdout/stderr with tokens)
const message = error instanceof Error ? error.message : 'unknown error';
debug('Failed to read from macOS Keychain:', message);
// Record failure for backoff to avoid re-prompting
recordKeychainFailure(homeDir, now);
return null;
}
}
/**
* Read credentials from file (legacy method).
* Older versions of Claude Code stored credentials in ~/.claude/.credentials.json
*/
function readFileCredentials(homeDir: string, now: number): { accessToken: string; subscriptionType: string } | null {
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
if (!fs.existsSync(credentialsPath)) {
return null;
}
try {
const content = fs.readFileSync(credentialsPath, 'utf8');
const data: CredentialsFile = JSON.parse(content);
return parseCredentialsData(data, now);
} catch (error) {
debug('Failed to read credentials file:', error);
return null;
}
}
/**
* Parse and validate credentials data from either Keychain or file.
*/
function parseCredentialsData(data: CredentialsFile, now: number): { accessToken: string; subscriptionType: string } | null {
const accessToken = data.claudeAiOauth?.accessToken;
const subscriptionType = data.claudeAiOauth?.subscriptionType ?? '';
if (!accessToken) {
return null;
}
// Check if token is expired (expiresAt is Unix ms timestamp)
// Use != null to handle expiresAt=0 correctly (would be expired)
const expiresAt = data.claudeAiOauth?.expiresAt;
if (expiresAt != null && expiresAt <= now) {
debug('OAuth token expired');
return null;
}
return { accessToken, subscriptionType };
}
/**
* Read OAuth credentials, trying macOS Keychain first (Claude Code 2.x),
* then falling back to file-based credentials (older versions).
*
* Token priority: Keychain token is authoritative (Claude Code 2.x stores current token there).
* SubscriptionType: Can be supplemented from file if keychain lacks it (display-only field).
*/
function readCredentials(
homeDir: string,
now: number,
readKeychain: (now: number, homeDir: string) => { accessToken: string; subscriptionType: string } | null
): { accessToken: string; subscriptionType: string } | null {
// Try macOS Keychain first (Claude Code 2.x)
const keychainCreds = readKeychain(now, homeDir);
if (keychainCreds) {
if (keychainCreds.subscriptionType) {
debug('Using credentials from macOS Keychain');
return keychainCreds;
}
// Keychain has token but no subscriptionType - try to supplement from file
const fileCreds = readFileCredentials(homeDir, now);
if (fileCreds?.subscriptionType) {
debug('Using keychain token with file subscriptionType');
return {
accessToken: keychainCreds.accessToken,
subscriptionType: fileCreds.subscriptionType,
};
}
// No subscriptionType available - use keychain token anyway
debug('Using keychain token without subscriptionType');
return keychainCreds;
}
// Fall back to file-based credentials (older versions or non-macOS)
const fileCreds = readFileCredentials(homeDir, now);
if (fileCreds) {
debug('Using credentials from file');
return fileCreds;
}
return null;
}
function getPlanName(subscriptionType: string): string | null {
const lower = subscriptionType.toLowerCase();
if (lower.includes('max')) return 'Max';
if (lower.includes('pro')) return 'Pro';
if (lower.includes('team')) return 'Team';
// API users don't have subscriptionType or have 'api'
if (!subscriptionType || lower.includes('api')) return null;
// Unknown subscription type - show it capitalized
return subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1);
}
/** Parse utilization value, clamping to 0-100 and handling NaN/Infinity */
function parseUtilization(value: number | undefined): number | null {
if (value == null) return null;
if (!Number.isFinite(value)) return null; // Handles NaN and Infinity
return Math.round(Math.max(0, Math.min(100, value)));
}
/** Parse ISO date string safely, returning null for invalid dates */
function parseDate(dateStr: string | undefined): Date | null {
if (!dateStr) return null;
const date = new Date(dateStr);
// Check for Invalid Date
if (isNaN(date.getTime())) {
debug('Invalid date string:', dateStr);
return null;
}
return date;
}
function fetchUsageApi(accessToken: string): Promise<UsageApiResponse | null> {
return new Promise((resolve) => {
const options = {
hostname: 'api.anthropic.com',
path: '/api/oauth/usage',
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'anthropic-beta': 'oauth-2025-04-20',
'User-Agent': 'claude-hud/1.0',
},
timeout: 5000,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
res.on('end', () => {
if (res.statusCode !== 200) {
debug('API returned non-200 status:', res.statusCode);
resolve(null);
return;
}
try {
const parsed: UsageApiResponse = JSON.parse(data);
resolve(parsed);
} catch (error) {
debug('Failed to parse API response:', error);
resolve(null);
}
});
});
req.on('error', (error) => {
debug('API request error:', error);
resolve(null);
});
req.on('timeout', () => {
debug('API request timeout');
req.destroy();
resolve(null);
});
req.end();
});
}
// Export for testing
export function clearCache(homeDir?: string): void {
if (homeDir) {
try {
const cachePath = getCachePath(homeDir);
if (fs.existsSync(cachePath)) {
fs.unlinkSync(cachePath);
}
} catch {
// Ignore
}
}
}