Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 7 additions & 26 deletions src/commands/blocks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import process from 'node:process';
import { define } from 'gunshi';
import pc from 'picocolors';
import { BLOCKS_COMPACT_WIDTH_THRESHOLD, BLOCKS_DEFAULT_TERMINAL_WIDTH, BLOCKS_WARNING_THRESHOLD, DEFAULT_RECENT_DAYS } from '../consts.internal.js';
import { getDefaultClaudePath, loadSessionBlockData } from '../data-loader.ts';
import { log, logger } from '../logger.ts';
import {
Expand All @@ -13,26 +14,6 @@ import {
import { sharedCommandConfig } from '../shared-args.internal.ts';
import { formatCurrency, formatModelsDisplay, formatNumber, ResponsiveTable } from '../utils.internal.ts';

/**
* Default number of recent days to show in blocks view
*/
const RECENT_DAYS_DEFAULT = 3;

/**
* Threshold percentage for showing usage warnings (80%)
*/
const WARNING_THRESHOLD = 0.8;

/**
* Terminal width threshold for switching to compact display mode
*/
const COMPACT_WIDTH_THRESHOLD = 120;

/**
* Default terminal width when stdout.columns is not available
*/
const DEFAULT_TERMINAL_WIDTH = 120;

/**
* Formats the time display for a session block
* @param block - Session block to format
Expand Down Expand Up @@ -135,7 +116,7 @@ export const blocksCommand = define({
recent: {
type: 'boolean',
short: 'r',
description: `Show blocks from last ${RECENT_DAYS_DEFAULT} days (including active)`,
description: `Show blocks from last ${DEFAULT_RECENT_DAYS} days (including active)`,
default: false,
},
tokenLimit: {
Expand Down Expand Up @@ -199,7 +180,7 @@ export const blocksCommand = define({

// Apply filters
if (ctx.values.recent) {
blocks = filterRecentBlocks(blocks, RECENT_DAYS_DEFAULT);
blocks = filterRecentBlocks(blocks, DEFAULT_RECENT_DAYS);
}

if (ctx.values.active) {
Expand Down Expand Up @@ -248,7 +229,7 @@ export const blocksCommand = define({
percentUsed: (projection.totalTokens / limit) * 100,
status: projection.totalTokens > limit
? 'exceeds'
: projection.totalTokens > limit * WARNING_THRESHOLD ? 'warning' : 'ok',
: projection.totalTokens > limit * BLOCKS_WARNING_THRESHOLD ? 'warning' : 'ok',
}
: undefined;
})()
Expand Down Expand Up @@ -309,7 +290,7 @@ export const blocksCommand = define({
const percentUsed = (projection.totalTokens / limit) * 100;
const status = percentUsed > 100
? pc.red('EXCEEDS LIMIT')
: percentUsed > WARNING_THRESHOLD * 100
: percentUsed > BLOCKS_WARNING_THRESHOLD * 100
? pc.yellow('WARNING')
: pc.green('OK');

Expand Down Expand Up @@ -348,8 +329,8 @@ export const blocksCommand = define({
});

// Detect if we need compact formatting
const terminalWidth = process.stdout.columns || DEFAULT_TERMINAL_WIDTH;
const useCompactFormat = terminalWidth < COMPACT_WIDTH_THRESHOLD;
const terminalWidth = process.stdout.columns || BLOCKS_DEFAULT_TERMINAL_WIDTH;
const useCompactFormat = terminalWidth < BLOCKS_COMPACT_WIDTH_THRESHOLD;

for (const block of blocks) {
if (block.isGap ?? false) {
Expand Down
5 changes: 3 additions & 2 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { serve } from '@hono/node-server';
import { define } from 'gunshi';
import { MCP_DEFAULT_PORT } from '../consts.internal.js';
import { getDefaultClaudePath } from '../data-loader.ts';
import { logger } from '../logger.ts';
import { createMcpHttpApp, createMcpServer, startMcpServerStdio } from '../mcp.ts';
Expand All @@ -23,8 +24,8 @@ export const mcpCommand = define({
},
port: {
type: 'number',
description: 'Port for HTTP transport (default: 8080)',
default: 8080,
description: `Port for HTTP transport (default: ${MCP_DEFAULT_PORT})`,
default: MCP_DEFAULT_PORT,
},
},
async run(ctx) {
Expand Down
62 changes: 62 additions & 0 deletions src/consts.internal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,67 @@
import { homedir } from 'node:os';

/**
* URL for LiteLLM's model pricing and context window data
*/
export const LITELLM_PRICING_URL
= 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';

/**
* Default number of recent days to include when filtering blocks
* Used in both session blocks and commands for consistent behavior
*/
export const DEFAULT_RECENT_DAYS = 3;

/**
* Threshold percentage for showing usage warnings in blocks command (80%)
* When usage exceeds this percentage of limits, warnings are displayed
*/
export const BLOCKS_WARNING_THRESHOLD = 0.8;

/**
* Terminal width threshold for switching to compact display mode in blocks command
* Below this width, tables use more compact formatting
*/
export const BLOCKS_COMPACT_WIDTH_THRESHOLD = 120;

/**
* Default terminal width when stdout.columns is not available in blocks command
* Used as fallback for responsive table formatting
*/
export const BLOCKS_DEFAULT_TERMINAL_WIDTH = 120;

/**
* Threshold percentage for considering costs as matching (0.1% tolerance)
* Used in debug cost validation to allow for minor calculation differences
*/
export const DEBUG_MATCH_THRESHOLD_PERCENT = 0.1;

/**
* Default Claude data directory path (~/.claude)
* Used as base path for loading usage data from JSONL files
*/
Comment on lines +40 to +42
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The JSDoc for DEFAULT_CLAUDE_CODE_PATH could be more precise. It currently states it's the "Default Claude data directory path (~/.claude)". However, with the refactoring, this constant now holds just the directory name (e.g., .claude), and the full path is constructed using USER_HOME_DIR (e.g., path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH)).

Consider updating the JSDoc to reflect that this constant represents the name or relative path component of the default Claude data directory, typically located within the user's home directory.

Suggested change
* Default Claude data directory path (~/.claude)
* Used as base path for loading usage data from JSONL files
*/
* Default name for the Claude data directory (e.g., '.claude')
* This directory is typically located in the user's home directory.
* Used as a base name for constructing the full path to load usage data.

export const DEFAULT_CLAUDE_CODE_PATH = '.claude';

/**
* Claude projects directory name within the data directory
* Contains subdirectories for each project with usage data
*/
export const CLAUDE_PROJECTS_DIR_NAME = 'projects';

/**
* JSONL file glob pattern for finding usage data files
* Used to recursively find all JSONL files in project directories
*/
export const USAGE_DATA_GLOB_PATTERN = '**/*.jsonl';

/**
* Default port for MCP server HTTP transport
* Used when no port is specified for MCP server communication
*/
export const MCP_DEFAULT_PORT = 8080;

/**
* User's home directory path
* Centralized access to OS home directory for consistent path building
*/
export const USER_HOME_DIR = homedir();
26 changes: 11 additions & 15 deletions src/data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createFixture } from 'fs-fixture';
import { isDirectorySync } from 'path-type';
import { glob } from 'tinyglobby';
import { z } from 'zod';
import { CLAUDE_PROJECTS_DIR_NAME, DEFAULT_CLAUDE_CODE_PATH, USAGE_DATA_GLOB_PATTERN, USER_HOME_DIR } from './consts.internal.js';
import { logger } from './logger.ts';
import {
PricingFetcher,
Expand Down Expand Up @@ -47,35 +48,30 @@ import {
versionSchema,
} from './types.internal.ts';

/**
* Default Claude data directory path (~/.claude)
*/
const DEFAULT_CLAUDE_CODE_PATH = path.join(homedir(), '.claude');

/**
* Default path for Claude data directory
* Uses environment variable CLAUDE_CONFIG_DIR if set, otherwise defaults to ~/.claude
*/
export function getDefaultClaudePath(): string {
const envClaudeCodePath = (process.env.CLAUDE_CONFIG_DIR ?? '').trim();
if (envClaudeCodePath === '') {
return DEFAULT_CLAUDE_CODE_PATH;
return path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH);
}

// First validate that the CLAUDE_CONFIG_DIR itself exists and is a directory
if (!isDirectorySync(envClaudeCodePath)) {
throw new Error(
`CLAUDE_CONFIG_DIR path is not a valid directory: ${envClaudeCodePath}.
Please set CLAUDE_CONFIG_DIR to a valid directory path, or ensure ${DEFAULT_CLAUDE_CODE_PATH} exists.
Please set CLAUDE_CONFIG_DIR to a valid directory path, or ensure ${path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH)} exists.
`.trim(),
);
}

const claudeCodeProjectsPath = path.join(envClaudeCodePath, 'projects');
const claudeCodeProjectsPath = path.join(envClaudeCodePath, CLAUDE_PROJECTS_DIR_NAME);
if (!isDirectorySync(claudeCodeProjectsPath)) {
throw new Error(
`Claude data directory does not exist: ${claudeCodeProjectsPath}.
Please set CLAUDE_CONFIG_DIR to a valid path, or ensure ${DEFAULT_CLAUDE_CODE_PATH} exists.
Please set CLAUDE_CONFIG_DIR to a valid path, or ensure ${path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH)} exists.
Comment on lines 73 to +74
Copy link

Copilot AI Jun 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message suggests ensuring the base directory exists, but this check is for the projects subdirectory. Consider updating it to reference CLAUDE_PROJECTS_DIR_NAME so the user knows to verify the 'projects' folder specifically.

Copilot uses AI. Check for mistakes.
`.trim(),
);
}
Expand Down Expand Up @@ -586,8 +582,8 @@ export async function loadDailyUsageData(
options?: LoadOptions,
): Promise<DailyUsage[]> {
const claudePath = options?.claudePath ?? getDefaultClaudePath();
const claudeDir = path.join(claudePath, 'projects');
const files = await glob(['**/*.jsonl'], {
const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME);
const files = await glob([USAGE_DATA_GLOB_PATTERN], {
cwd: claudeDir,
absolute: true,
});
Expand Down Expand Up @@ -708,8 +704,8 @@ export async function loadSessionData(
options?: LoadOptions,
): Promise<SessionUsage[]> {
const claudePath = options?.claudePath ?? getDefaultClaudePath();
const claudeDir = path.join(claudePath, 'projects');
const files = await glob(['**/*.jsonl'], {
const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME);
const files = await glob([USAGE_DATA_GLOB_PATTERN], {
cwd: claudeDir,
absolute: true,
});
Expand Down Expand Up @@ -944,8 +940,8 @@ export async function loadSessionBlockData(
options?: LoadOptions,
): Promise<SessionBlock[]> {
const claudePath = options?.claudePath ?? getDefaultClaudePath();
const claudeDir = path.join(claudePath, 'projects');
const files = await glob(['**/*.jsonl'], {
const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME);
const files = await glob([USAGE_DATA_GLOB_PATTERN], {
cwd: claudeDir,
absolute: true,
});
Expand Down
12 changes: 4 additions & 8 deletions src/debug.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
import { createFixture } from 'fs-fixture';
import { glob } from 'tinyglobby';
import { CLAUDE_PROJECTS_DIR_NAME, DEBUG_MATCH_THRESHOLD_PERCENT, DEFAULT_CLAUDE_CODE_PATH, USAGE_DATA_GLOB_PATTERN, USER_HOME_DIR } from './consts.internal.js';
Copy link

Copilot AI Jun 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Imports use a mix of .js and .ts extensions (e.g., ./consts.internal.js vs ./data-loader.ts). Consider standardizing on one extension style to reduce import confusion.

Suggested change
import { CLAUDE_PROJECTS_DIR_NAME, DEBUG_MATCH_THRESHOLD_PERCENT, DEFAULT_CLAUDE_CODE_PATH, USAGE_DATA_GLOB_PATTERN, USER_HOME_DIR } from './consts.internal.js';
import { CLAUDE_PROJECTS_DIR_NAME, DEBUG_MATCH_THRESHOLD_PERCENT, DEFAULT_CLAUDE_CODE_PATH, USAGE_DATA_GLOB_PATTERN, USER_HOME_DIR } from './consts.internal.ts';

Copilot uses AI. Check for mistakes.
import { usageDataSchema } from './data-loader.ts';
import { logger } from './logger.ts';
import { PricingFetcher } from './pricing-fetcher.ts';

/**
* Threshold percentage for considering costs as matching (0.1% tolerance)
*/
const MATCH_THRESHOLD_PERCENT = 0.1;
/**
* Represents a pricing discrepancy between original and calculated costs
*/
Expand Down Expand Up @@ -68,8 +64,8 @@ type MismatchStats = {
export async function detectMismatches(
claudePath?: string,
): Promise<MismatchStats> {
const claudeDir = claudePath ?? path.join(homedir(), '.claude', 'projects');
const files = await glob(['**/*.jsonl'], {
const claudeDir = claudePath ?? path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH, CLAUDE_PROJECTS_DIR_NAME);
const files = await glob([USAGE_DATA_GLOB_PATTERN], {
cwd: claudeDir,
absolute: true,
});
Expand Down Expand Up @@ -145,7 +141,7 @@ export async function detectMismatches(
versionStat.total++;

// Consider it a match if within the defined threshold (to account for floating point)
if (percentDiff < MATCH_THRESHOLD_PERCENT) {
if (percentDiff < DEBUG_MATCH_THRESHOLD_PERCENT) {
versionStat.matches++;
}
else {
Expand Down
6 changes: 1 addition & 5 deletions src/session-blocks.internal.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { uniq } from 'es-toolkit';
import { DEFAULT_RECENT_DAYS } from './consts.internal.js';

/**
* Default session duration in hours (Claude's billing block duration)
*/
export const DEFAULT_SESSION_DURATION_HOURS = 5;

/**
* Default number of recent days to include when filtering blocks
*/
const DEFAULT_RECENT_DAYS = 3;

/**
* Represents a single usage data entry loaded from JSONL files
*/
Expand Down