feat(ccusage): add --custom-dirs flag for additional Claude data directories by lukasedw · Pull Request #947 · ccusage/ccusage · GitHub
Skip to content

feat(ccusage): add --custom-dirs flag for additional Claude data directories#947

Closed
lukasedw wants to merge 2 commits into
ccusage:mainfrom
lukasedw:feat/custom-dirs
Closed

feat(ccusage): add --custom-dirs flag for additional Claude data directories#947
lukasedw wants to merge 2 commits into
ccusage:mainfrom
lukasedw:feat/custom-dirs

Conversation

@lukasedw

@lukasedw lukasedw commented Apr 18, 2026

Copy link
Copy Markdown

Summary

Adds a --custom-dirs CLI flag that accepts a comma-separated list of additional Claude data directories. These are merged with the defaults (~/.config/claude, ~/.claude) and anything set via CLAUDE_CONFIG_DIR, deduplicated, and validated (each entry must contain a projects/ subdirectory).

# Per-invocation (no shell config needed)
ccusage daily --custom-dirs ~/.claude-work,~/.claude-personal

# Composes with the env var
CLAUDE_CONFIG_DIR=/archive/claude ccusage monthly --custom-dirs ~/.claude-work

What Changed

  • _shared-args.ts — new customDirs shared arg.
  • data-loader.tsgetClaudePaths(customDirs?) refactored with an addIfValid helper, ~/ home-dir expansion, and dedup across env-var + custom-dirs. LoadOptions.customDirs threaded through loadDailyUsageData, loadSessionData, loadSessionBlockData, and loadSessionUsageById.
  • Commandssession, blocks, statusline and the shared _session_id helper now pass customDirs to their loaders. daily, monthly, and weekly already spread mergedOptions and pick it up for free.
  • config-schema.json — regenerated from _shared-args.ts via pnpm run generate:schema.
  • Docsdocs/guide/custom-paths.md, docs/guide/cli-options.md, docs/guide/config-files.md, ccusage.example.json, apps/ccusage/README.md.
  • _config-loader-tokens.ts (separate commit) — fix a latent bug where getConfigSearchPaths threw during config discovery on systems where no default Claude dir exists (e.g. fresh installs, containerized dev envs, systems relying entirely on --custom-dirs). Wrapped in try/catch so the correct error surfaces later in the data-loading layer.

Why

Users with multiple Claude Code installs (work, personal, worktrees, archives) have to concatenate everything into a single CLAUDE_CONFIG_DIR env var — awkward for one-off analysis and impossible to express cleanly in ccusage.json. --custom-dirs lets them add dirs inline per-invocation or set a default via config file, and composes with the env var.

Test Plan

  • 7 new in-source vitest cases in data-loader.ts covering: CSV parsing, env-var ∪ custom-dirs dedup, empty/whitespace no-op, trailing comma tolerance, invalid-dir silent drop, and the empty-CLAUDE_CONFIG_DIR-rescue scenario.
  • pnpm run test — all tests pass (1 pre-existing test that depends on ~/.claude/projects existing on the dev machine continues to pass on CI).
  • pnpm run format / pnpm typecheck — clean.
  • Manual: pnpm --filter ccusage run start daily --custom-dirs ~/.claude-work,~/.claude-personal aggregates data from both dirs.

Known limitations / follow-ups

  • MCP package (apps/mcp) still only reads claudePath from LoadOptions — it will silently ignore customDirs from MCP clients. Happy to wire this up in a follow-up PR if you'd prefer to keep this one scoped to the CLI.
  • Array form in config JSON — the generated schema declares customDirs as a string only. Users may naturally write "customDirs": ["/a", "/b"]. Can extend the schema + merger to accept both forms if preferred.
  • Shell globs--custom-dirs ~/.claude* is not expanded by ccusage (users must list paths explicitly or rely on shell expansion into comma-separated form). Documented as a known behavior.

Summary by CodeRabbit

  • New Features

    • Added --custom-dirs CLI flag to specify comma-separated paths for extra Claude data directories, merging with defaults and CLAUDE_CONFIG_DIR.
    • Added customDirs configuration option in ccusage.json for default custom directory paths.
    • Custom directories require a projects/ subdirectory; home directory expansion (~/) is supported.
  • Documentation

    • Updated CLI options, configuration, and custom paths guides with examples and behavior details.

`getConfigSearchPaths` calls `getClaudePaths()` to locate `ccusage.json`
in user Claude data directories. On systems where none of the default
Claude directories exist (fresh install, containerized dev env, systems
using only `--custom-dirs` or a non-standard `CLAUDE_CONFIG_DIR`), this
call threw before CLI args had been parsed — making the user-facing
error about data loading impossible to reach.

Wrap the call in try/catch; on failure, fall back to searching only the
local `.ccusage/ccusage.json`. The actual data loader still surfaces
the "no Claude dirs" error at the right layer with the right context.
…ctories

Adds a `--custom-dirs` CLI flag that accepts a comma-separated list of
extra Claude data directories to include alongside the defaults
(`~/.config/claude`, `~/.claude`) and anything set via `CLAUDE_CONFIG_DIR`.

What changed:

- `_shared-args.ts` — new `customDirs` shared arg
- `data-loader.ts` — `getClaudePaths(customDirs?)` refactored with an
  `addIfValid` helper, `~/` home-dir expansion, and dedup across env
  var and custom dirs. Each entry is validated by `projects/` subdir
  existence, so invalid paths are silently dropped. `LoadOptions.customDirs`
  threaded through `loadDailyUsageData`, `loadSessionData`,
  `loadSessionBlockData`, and `loadSessionUsageById`.
- Commands (`session`, `blocks`, `statusline` + `_session_id` helper)
  pass `customDirs` to their loaders. `daily`, `monthly`, and `weekly`
  already spread `mergedOptions` and pick it up automatically.
- `config-schema.json` regenerated from `_shared-args.ts`.
- Docs: `custom-paths.md`, `cli-options.md`, `config-files.md`,
  `ccusage.example.json`, `README.md`.
- Tests: 7 new in-source vitest cases covering comma-separated
  parsing, env-var ∪ custom-dirs dedup, empty/whitespace no-op,
  trailing comma, invalid-dir drop, and the empty-env-var rescue
  scenario.

Why:

Users with multiple Claude Code installs (work, personal, worktrees,
archive) today have to concatenate everything into a single
`CLAUDE_CONFIG_DIR` env var. `--custom-dirs` lets them add extra dirs
inline per invocation without shell configuration, and composes with
the env var — both sources are merged and deduplicated.
@coderabbitai

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/ccusage/config-schema.json (1)

651-705: ⚠️ Potential issue | 🟡 Minor

Add customDirs to the statusline command schema.

statusline.ts exposes customDirs, but commands.statusline still has additionalProperties: false without a customDirs property, so command-specific statusline config is rejected by schema validation/autocomplete.

Proposed schema addition
 								"debug": {
 									"type": "boolean",
 									"description": "Show pricing mismatch information for debugging",
 									"markdownDescription": "Show pricing mismatch information for debugging",
 									"default": false
+								},
+								"customDirs": {
+									"type": "string",
+									"description": "Additional Claude data directories to include (comma-separated paths). Each must contain a projects/ subdirectory.",
+									"markdownDescription": "Additional Claude data directories to include (comma-separated paths). Each must contain a projects/ subdirectory."
 								}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/config-schema.json` around lines 651 - 705, Add a new
"customDirs" property to the "statusline" object schema so the CLI accepts the
statusline.ts option; specifically, inside the "statusline" properties (the same
object containing "offline", "visualBurnRate", "costSource", etc.) add
"customDirs" with type "array" and items of type "string", include a short
"description"/"markdownDescription" and a sensible "default" (e.g., an empty
array), leaving "additionalProperties": false in place; this ensures the
statusline configuration (and the statusline.ts exported customDirs) validates
and is autocompleted.
apps/ccusage/src/data-loader.ts (1)

98-138: ⚠️ Potential issue | 🟠 Major

Align path merging and ~/ expansion across all path sources.

The new docs describe customDirs as additive to defaults and CLAUDE_CONFIG_DIR, but this branch still skips defaults whenever CLAUDE_CONFIG_DIR is set. Also, ~/ expansion is applied only to customDirs, so CLAUDE_CONFIG_DIR="~/.claude" is resolved relative to the cwd instead of the home directory.

Centralize parsing/normalization and add defaults independently of env/custom sources.

🐛 Proposed direction
 export function getClaudePaths(customDirs?: string): string[] {
 	const paths: string[] = [];
 	const normalizedPaths = new Set<string>();

+	function expandHome(dirPath: string): string {
+		return dirPath.startsWith("~/")
+			? path.join(USER_HOME_DIR, dirPath.slice(2))
+			: dirPath;
+	}
+
+	function parsePathList(pathList: string): string[] {
+		return pathList
+			.split(",")
+			.map((p) => p.trim())
+			.filter((p) => p !== "")
+			.map(expandHome);
+	}
+
 	/**
 	 * Try to add a directory path if it's valid (exists and has projects/ subdir)
 	 */
 	function addIfValid(dirPath: string): void {
 		const normalizedPath = path.resolve(dirPath);
 		if (isDirectorySync(normalizedPath)) {
 			const projectsPath = path.join(normalizedPath, CLAUDE_PROJECTS_DIR_NAME);
 			if (isDirectorySync(projectsPath)) {
 				if (!normalizedPaths.has(normalizedPath)) {
 					normalizedPaths.add(normalizedPath);
 					paths.push(normalizedPath);
 				}
 			}
 		}
 	}

-	// Check environment variable first (supports comma-separated paths)
+	// Always include standard Claude locations first.
+	const defaultPaths = [
+		DEFAULT_CLAUDE_CONFIG_PATH,
+		path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH),
+	];
+	for (const defaultPath of defaultPaths) {
+		addIfValid(defaultPath);
+	}
+
+	// Add environment variable paths (supports comma-separated paths)
 	const envPaths = (process.env[CLAUDE_CONFIG_DIR_ENV] ?? '').trim();
 	if (envPaths !== '') {
-		const envPathList = envPaths
-			.split(',')
-			.map((p) => p.trim())
-			.filter((p) => p !== '');
-		for (const envPath of envPathList) {
+		for (const envPath of parsePathList(envPaths)) {
 			addIfValid(envPath);
 		}
-		// If environment variable is set but no valid paths found (before custom dirs), throw error
-		if (paths.length === 0 && (customDirs == null || customDirs.trim() === '')) {
-			throw new Error(
-				`No valid Claude data directories found in CLAUDE_CONFIG_DIR. Please ensure the following exists:
-- ${envPaths}/${CLAUDE_PROJECTS_DIR_NAME}`.trim(),
-			);
-		}
-	} else {
-		// Only check default paths if no environment variable is set
-		const defaultPaths = [
-			DEFAULT_CLAUDE_CONFIG_PATH, // New default: XDG config directory
-			path.join(USER_HOME_DIR, DEFAULT_CLAUDE_CODE_PATH), // Old default: ~/.claude
-		];
-
-		for (const defaultPath of defaultPaths) {
-			addIfValid(defaultPath);
-		}
 	}

 	// Add custom directories (always, regardless of env var)
 	if (customDirs != null && customDirs.trim() !== '') {
-		const customDirList = customDirs
-			.split(',')
-			.map((p) => p.trim())
-			.filter((p) => p !== '');
-		for (const customDir of customDirList) {
-			// Expand ~ to home directory
-			const expandedPath = customDir.startsWith('~/')
-				? path.join(USER_HOME_DIR, customDir.slice(2))
-				: customDir;
-			addIfValid(expandedPath);
+		for (const customDir of parsePathList(customDirs)) {
+			addIfValid(customDir);
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/data-loader.ts` around lines 98 - 138, The code currently
treats CLAUDE_CONFIG_DIR as replacing defaults and only expands "~/" for
customDirs; update the path-collection logic so defaults
(DEFAULT_CLAUDE_CONFIG_PATH and path.join(USER_HOME_DIR,
DEFAULT_CLAUDE_CODE_PATH)) are always included, and parse/normalize all sources
(env via CLAUDE_CONFIG_DIR_ENV, customDirs, and defaults) using a single helper
normalization step that expands "~/" using USER_HOME_DIR, trims, splits comma
lists, filters empties, and then calls addIfValid for each resolved path; ensure
the branch that reads CLAUDE_CONFIG_DIR no longer skips adding defaults and
throw the existing error only after normalization if paths remains empty.
🧹 Nitpick comments (1)
apps/ccusage/src/_config-loader-tokens.ts (1)

58-63: Use Result.try() instead of adding try/catch.

This keeps config discovery aligned with the repo’s existing Result-based error handling.

Proposed refactor
-	let claudePathDirs: string[] = [];
-	try {
-		claudePathDirs = toArray(getClaudePaths());
-	} catch {
-		// No Claude data dirs found — still allow local config discovery
-	}
+	const claudePathDirs = Result.pipe(
+		Result.try({
+			try: () => toArray(getClaudePaths()),
+			catch: (error) => error,
+		})(),
+		Result.unwrap([]),
+	);

As per coding guidelines, Prefer @praha/byethrow Result type over traditional try-catch for functional error handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/_config-loader-tokens.ts` around lines 58 - 63, Replace the
try/catch around populating claudePathDirs with a Result.try() call so errors
from getClaudePaths() are handled via the project's Result type; call
Result.try(() => toArray(getClaudePaths())) and on success assign to
claudePathDirs, leaving the error branch to be ignored/handled (maintaining the
“allow local config discovery” behavior). Update references to claudePathDirs,
toArray, getClaudePaths, and ensure you import/use Result from `@praha/byethrow`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/ccusage/src/commands/session.ts`:
- Line 76: The debug mismatch path ignores user-provided customDirs: when you
call detectMismatches() from the session command you must thread the same
resolved customDirs used by loadSessionData so detection uses identical paths.
Locate the session command where mergedOptions.customDirs is built and either
(A) call detectMismatches(mergedOptions.customDirs) (and update detectMismatches
signature/uses accordingly) or (B) resolve the custom paths via
getClaudePaths(mergedOptions.customDirs) and pass those resolved paths into
detectMismatches; update the detectMismatches implementation to accept and use
the customDirs/resolved paths if needed so --debug uses the same directory
context as loadSessionData.

In `@apps/ccusage/src/commands/statusline.ts`:
- Around line 293-297: The statusline loader calls are passing
ctx.values.customDirs instead of honoring mergedOptions (so defaults/command
config get ignored); update each call to loadSessionUsageById (and the other
similar calls present later in the file) to pass mergedOptions.customDirs
instead of ctx.values.customDirs so the effective config (mergedOptions) is
used; search for all invocations of loadSessionUsageById in this file (including
the other two occurrences noted) and replace the customDirs argument with
mergedOptions.customDirs.

In `@docs/guide/cli-options.md`:
- Around line 160-174: The docs claim extra Claude data directories are added
"alongside the defaults", but the loader behavior only checks defaults when
CLAUDE_CONFIG_DIR is unset; update the wording in the CLI docs to accurately
reflect this: change the sentence to state that --custom-dirs is added to the
resolved set of directories only when the loader falls back to defaults (i.e.,
when CLAUDE_CONFIG_DIR is unset), and link to the loader behavior for clarity;
reference the CLAUDE_CONFIG_DIR env var and the --custom-dirs flag so readers
know the exact symbols affected.

---

Outside diff comments:
In `@apps/ccusage/config-schema.json`:
- Around line 651-705: Add a new "customDirs" property to the "statusline"
object schema so the CLI accepts the statusline.ts option; specifically, inside
the "statusline" properties (the same object containing "offline",
"visualBurnRate", "costSource", etc.) add "customDirs" with type "array" and
items of type "string", include a short "description"/"markdownDescription" and
a sensible "default" (e.g., an empty array), leaving "additionalProperties":
false in place; this ensures the statusline configuration (and the statusline.ts
exported customDirs) validates and is autocompleted.

In `@apps/ccusage/src/data-loader.ts`:
- Around line 98-138: The code currently treats CLAUDE_CONFIG_DIR as replacing
defaults and only expands "~/" for customDirs; update the path-collection logic
so defaults (DEFAULT_CLAUDE_CONFIG_PATH and path.join(USER_HOME_DIR,
DEFAULT_CLAUDE_CODE_PATH)) are always included, and parse/normalize all sources
(env via CLAUDE_CONFIG_DIR_ENV, customDirs, and defaults) using a single helper
normalization step that expands "~/" using USER_HOME_DIR, trims, splits comma
lists, filters empties, and then calls addIfValid for each resolved path; ensure
the branch that reads CLAUDE_CONFIG_DIR no longer skips adding defaults and
throw the existing error only after normalization if paths remains empty.

---

Nitpick comments:
In `@apps/ccusage/src/_config-loader-tokens.ts`:
- Around line 58-63: Replace the try/catch around populating claudePathDirs with
a Result.try() call so errors from getClaudePaths() are handled via the
project's Result type; call Result.try(() => toArray(getClaudePaths())) and on
success assign to claudePathDirs, leaving the error branch to be ignored/handled
(maintaining the “allow local config discovery” behavior). Update references to
claudePathDirs, toArray, getClaudePaths, and ensure you import/use Result from
`@praha/byethrow`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9868a2cc-cffb-42e7-a001-27e954f7cbfa

📥 Commits

Reviewing files that changed from the base of the PR and between 291cddb and 01d8961.

📒 Files selected for processing (13)
  • apps/ccusage/README.md
  • apps/ccusage/config-schema.json
  • apps/ccusage/src/_config-loader-tokens.ts
  • apps/ccusage/src/_shared-args.ts
  • apps/ccusage/src/commands/_session_id.ts
  • apps/ccusage/src/commands/blocks.ts
  • apps/ccusage/src/commands/session.ts
  • apps/ccusage/src/commands/statusline.ts
  • apps/ccusage/src/data-loader.ts
  • ccusage.example.json
  • docs/guide/cli-options.md
  • docs/guide/config-files.md
  • docs/guide/custom-paths.md

offline: ctx.values.offline,
timezone: ctx.values.timezone,
locale: ctx.values.locale,
customDirs: mergedOptions.customDirs,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Thread customDirs into the debug mismatch path too.

loadSessionData now reads custom directories, but --debug still calls detectMismatches(undefined), which resolves paths via getClaudePaths() without customDirs. ccusage session --custom-dirs /valid --debug can therefore fail or report mismatches for a different directory set.

Consider passing the same resolved custom path context into detectMismatches or updating detectMismatches to accept customDirs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/session.ts` at line 76, The debug mismatch path
ignores user-provided customDirs: when you call detectMismatches() from the
session command you must thread the same resolved customDirs used by
loadSessionData so detection uses identical paths. Locate the session command
where mergedOptions.customDirs is built and either (A) call
detectMismatches(mergedOptions.customDirs) (and update detectMismatches
signature/uses accordingly) or (B) resolve the custom paths via
getClaudePaths(mergedOptions.customDirs) and pass those resolved paths into
detectMismatches; update the detectMismatches implementation to accept and use
the customDirs/resolved paths if needed so --debug uses the same directory
context as loadSessionData.

Comment on lines 293 to 297
loadSessionUsageById(sessionId, {
mode: 'auto',
offline: mergedOptions.offline,
customDirs: ctx.values.customDirs,
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use mergedOptions.customDirs so config values are honored.

These loader calls currently read only direct CLI values, so customDirs from defaults or command config is ignored for statusline.

Proposed fix
 										loadSessionUsageById(sessionId, {
 											mode: 'auto',
 											offline: mergedOptions.offline,
-											customDirs: ctx.values.customDirs,
+											customDirs: mergedOptions.customDirs,
 										}),
 								loadDailyUsageData({
 									since: todayStr,
 									until: todayStr,
 									mode: 'auto',
 									offline: mergedOptions.offline,
-									customDirs: ctx.values.customDirs,
+									customDirs: mergedOptions.customDirs,
 								}),
 								loadSessionBlockData({
 									mode: 'auto',
 									offline: mergedOptions.offline,
-									customDirs: ctx.values.customDirs,
+									customDirs: mergedOptions.customDirs,
 								}),

Also applies to: 344-350, 368-372

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/statusline.ts` around lines 293 - 297, The
statusline loader calls are passing ctx.values.customDirs instead of honoring
mergedOptions (so defaults/command config get ignored); update each call to
loadSessionUsageById (and the other similar calls present later in the file) to
pass mergedOptions.customDirs instead of ctx.values.customDirs so the effective
config (mergedOptions) is used; search for all invocations of
loadSessionUsageById in this file (including the other two occurrences noted)
and replace the customDirs argument with mergedOptions.customDirs.

Comment thread docs/guide/cli-options.md
Comment on lines +160 to +174
Add extra Claude data directories inline, alongside the defaults (`~/.config/claude`, `~/.claude`) and anything from `CLAUDE_CONFIG_DIR`:

```bash
# Single extra directory
ccusage daily --custom-dirs ~/.claude-work

# Multiple directories (comma-separated)
ccusage daily --custom-dirs ~/.claude-work,~/.claude-personal

# Works with every data command
ccusage monthly --custom-dirs ~/.claude-archive
ccusage blocks --active --custom-dirs ~/.claude-work
```

Paths starting with `~/` are expanded to the user's home directory. Entries without a `projects/` subdirectory are silently skipped. See [Custom Paths](/guide/custom-paths#custom-dirs-cli-flag) for details on how this composes with `CLAUDE_CONFIG_DIR`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Align the CLAUDE_CONFIG_DIR wording with actual path resolution.

The loader only checks default dirs when CLAUDE_CONFIG_DIR is unset, so this currently isn’t “alongside the defaults” when the env var is present. Either update the loader to include defaults too, or adjust this wording.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/guide/cli-options.md` around lines 160 - 174, The docs claim extra
Claude data directories are added "alongside the defaults", but the loader
behavior only checks defaults when CLAUDE_CONFIG_DIR is unset; update the
wording in the CLI docs to accurately reflect this: change the sentence to state
that --custom-dirs is added to the resolved set of directories only when the
loader falls back to defaults (i.e., when CLAUDE_CONFIG_DIR is unset), and link
to the loader behavior for clarity; reference the CLAUDE_CONFIG_DIR env var and
the --custom-dirs flag so readers know the exact symbols affected.

@ryoppippi

Copy link
Copy Markdown
Member

@ryoppippi ryoppippi closed this May 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants