feat(ccusage): add --custom-dirs flag for additional Claude data directories#947
feat(ccusage): add --custom-dirs flag for additional Claude data directories#947lukasedw wants to merge 2 commits into
Conversation
`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.
There was a problem hiding this comment.
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 | 🟡 MinorAdd
customDirsto thestatuslinecommand schema.
statusline.tsexposescustomDirs, butcommands.statuslinestill hasadditionalProperties: falsewithout acustomDirsproperty, 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 | 🟠 MajorAlign path merging and
~/expansion across all path sources.The new docs describe
customDirsas additive to defaults andCLAUDE_CONFIG_DIR, but this branch still skips defaults wheneverCLAUDE_CONFIG_DIRis set. Also,~/expansion is applied only tocustomDirs, soCLAUDE_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: UseResult.try()instead of addingtry/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/byethrowResult 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
📒 Files selected for processing (13)
apps/ccusage/README.mdapps/ccusage/config-schema.jsonapps/ccusage/src/_config-loader-tokens.tsapps/ccusage/src/_shared-args.tsapps/ccusage/src/commands/_session_id.tsapps/ccusage/src/commands/blocks.tsapps/ccusage/src/commands/session.tsapps/ccusage/src/commands/statusline.tsapps/ccusage/src/data-loader.tsccusage.example.jsondocs/guide/cli-options.mddocs/guide/config-files.mddocs/guide/custom-paths.md
| offline: ctx.values.offline, | ||
| timezone: ctx.values.timezone, | ||
| locale: ctx.values.locale, | ||
| customDirs: mergedOptions.customDirs, |
There was a problem hiding this comment.
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.
| loadSessionUsageById(sessionId, { | ||
| mode: 'auto', | ||
| offline: mergedOptions.offline, | ||
| customDirs: ctx.values.customDirs, | ||
| }), |
There was a problem hiding this comment.
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.
| 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`. |
There was a problem hiding this comment.
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.

Summary
Adds a
--custom-dirsCLI flag that accepts a comma-separated list of additional Claude data directories. These are merged with the defaults (~/.config/claude,~/.claude) and anything set viaCLAUDE_CONFIG_DIR, deduplicated, and validated (each entry must contain aprojects/subdirectory).What Changed
_shared-args.ts— newcustomDirsshared arg.data-loader.ts—getClaudePaths(customDirs?)refactored with anaddIfValidhelper,~/home-dir expansion, and dedup across env-var + custom-dirs.LoadOptions.customDirsthreaded throughloadDailyUsageData,loadSessionData,loadSessionBlockData, andloadSessionUsageById.session,blocks,statuslineand the shared_session_idhelper now passcustomDirsto their loaders.daily,monthly, andweeklyalready spreadmergedOptionsand pick it up for free.config-schema.json— regenerated from_shared-args.tsviapnpm run generate:schema.docs/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 wheregetConfigSearchPathsthrew 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_DIRenv var — awkward for one-off analysis and impossible to express cleanly inccusage.json.--custom-dirslets them add dirs inline per-invocation or set a default via config file, and composes with the env var.Test Plan
data-loader.tscovering: 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/projectsexisting on the dev machine continues to pass on CI).pnpm run format/pnpm typecheck— clean.pnpm --filter ccusage run start daily --custom-dirs ~/.claude-work,~/.claude-personalaggregates data from both dirs.Known limitations / follow-ups
apps/mcp) still only readsclaudePathfromLoadOptions— it will silently ignorecustomDirsfrom MCP clients. Happy to wire this up in a follow-up PR if you'd prefer to keep this one scoped to the CLI.customDirsas a string only. Users may naturally write"customDirs": ["/a", "/b"]. Can extend the schema + merger to accept both forms if preferred.--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
--custom-dirsCLI flag to specify comma-separated paths for extra Claude data directories, merging with defaults andCLAUDE_CONFIG_DIR.customDirsconfiguration option inccusage.jsonfor default custom directory paths.projects/subdirectory; home directory expansion (~/) is supported.Documentation