+110
-27
@@ -25,3 +25,3 @@ #!/usr/bin/env node | ||
| function getUsageCacheFilePath() { | ||
| return path.join(getCacheRoot(), "usage-cache-v1.json"); | ||
| return path.join(getCacheRoot(), "usage-cache-v2.json"); | ||
| } | ||
@@ -32,4 +32,11 @@ function hasUsageCache() { | ||
| function isValidCacheFile(value) { | ||
| return typeof value === "object" && value !== null && "version" in value && value.version === 1 && "files" in value && typeof value.files === "object" && value.files !== null; | ||
| return typeof value === "object" && value !== null && "version" in value && value.version === 2 && "files" in value && typeof value.files === "object" && value.files !== null; | ||
| } | ||
| function getCacheKey(filePath, variant) { | ||
| return `${variant}\x00${filePath}`; | ||
| } | ||
| function getFilePathFromCacheKey(key) { | ||
| const separatorIndex = key.indexOf("\x00"); | ||
| return separatorIndex === -1 ? key : key.slice(separatorIndex + 1); | ||
| } | ||
@@ -44,8 +51,8 @@ class UsageCacheStore { | ||
| } | ||
| get(filePath, source, mtimeMs, size) { | ||
| const cached = this.data.files[filePath]; | ||
| get(filePath, source, mtimeMs, size, variant = "default") { | ||
| const cached = this.data.files[getCacheKey(filePath, variant)]; | ||
| if (!cached) { | ||
| return null; | ||
| } | ||
| if (cached.source !== source || cached.mtimeMs !== mtimeMs || cached.size !== size) { | ||
| if (cached.source !== source || cached.mtimeMs !== mtimeMs || cached.size !== size || cached.variant !== variant) { | ||
| return null; | ||
@@ -56,5 +63,5 @@ } | ||
| prune(source, existingFiles) { | ||
| for (const [filePath, entry] of Object.entries(this.data.files)) { | ||
| if (entry.source === source && !existingFiles.has(filePath)) { | ||
| delete this.data.files[filePath]; | ||
| for (const [cacheKey, entry] of Object.entries(this.data.files)) { | ||
| if (entry.source === source && !existingFiles.has(getFilePathFromCacheKey(cacheKey))) { | ||
| delete this.data.files[cacheKey]; | ||
| this.dirty = true; | ||
@@ -72,4 +79,4 @@ } | ||
| } | ||
| set(filePath, source, mtimeMs, size, records, latestRateLimits) { | ||
| this.data.files[filePath] = { | ||
| set(filePath, source, mtimeMs, size, records, latestRateLimits, variant = "default") { | ||
| this.data.files[getCacheKey(filePath, variant)] = { | ||
| latestRateLimits, | ||
@@ -79,3 +86,4 @@ mtimeMs, | ||
| size, | ||
| source | ||
| source, | ||
| variant | ||
| }; | ||
@@ -87,8 +95,8 @@ this.dirty = true; | ||
| if (!fs.existsSync(this.cacheFilePath)) { | ||
| return { version: 1, files: {} }; | ||
| return { version: 2, files: {} }; | ||
| } | ||
| const parsed = JSON.parse(fs.readFileSync(this.cacheFilePath, "utf8")); | ||
| return isValidCacheFile(parsed) ? parsed : { version: 1, files: {} }; | ||
| return isValidCacheFile(parsed) ? parsed : { version: 2, files: {} }; | ||
| } catch { | ||
| return { version: 1, files: {} }; | ||
| return { version: 2, files: {} }; | ||
| } | ||
@@ -734,3 +742,3 @@ } | ||
| function getPricingNote() { | ||
| return "Estimated USD using embedded Claude and Codex token pricing. Not a vendor billing statement."; | ||
| return "Estimated USD using embedded Claude and Codex token pricing. Claude totals include sidechains/subagents by default unless --main-thread-only is set. Not a vendor billing statement."; | ||
| } | ||
@@ -768,5 +776,14 @@ | ||
| function parseSessionRelativeDir(filePath, transcriptRoot) { | ||
| const relativeDir = path2.relative(transcriptRoot, path2.dirname(filePath)); | ||
| const relativeFilePath = path2.relative(transcriptRoot, filePath); | ||
| const pathParts = relativeFilePath.split(path2.sep).filter(Boolean); | ||
| if (pathParts.length >= 3 && pathParts[pathParts.length - 2] === "subagents") { | ||
| const projectParts = pathParts.slice(0, -3); | ||
| return projectParts.length === 0 ? "." : projectParts.join(path2.sep); | ||
| } | ||
| const relativeDir = path2.dirname(relativeFilePath); | ||
| return relativeDir === "" ? "." : relativeDir; | ||
| } | ||
| function isSubagentTranscriptFile(filePath) { | ||
| return path2.basename(path2.dirname(filePath)) === "subagents"; | ||
| } | ||
| function parseClaudeUsagePayload(value) { | ||
@@ -801,3 +818,12 @@ if (!isObject(value)) { | ||
| } | ||
| function parseUsageFile(filePath, transcriptRoot) { | ||
| function shouldSkipClaudeRecord(parsed, filePath, options) { | ||
| if (!isObject(parsed) || parsed.type !== "assistant") { | ||
| return true; | ||
| } | ||
| if (!options.mainThreadOnly) { | ||
| return false; | ||
| } | ||
| return parsed.isSidechain === true || isSubagentTranscriptFile(filePath); | ||
| } | ||
| function parseUsageFile(filePath, transcriptRoot, options) { | ||
| const raw = fs2.readFileSync(filePath, "utf8"); | ||
@@ -819,5 +845,8 @@ const lines = raw.split(` | ||
| } | ||
| if (!isObject(parsed) || parsed.isSidechain === true || parsed.type !== "assistant") { | ||
| if (shouldSkipClaudeRecord(parsed, filePath, options)) { | ||
| continue; | ||
| } | ||
| if (!isObject(parsed)) { | ||
| continue; | ||
| } | ||
| const timestamp = typeof parsed.timestamp === "string" ? parsed.timestamp : null; | ||
@@ -869,2 +898,24 @@ const message = isObject(parsed.message) ? parsed.message : null; | ||
| } | ||
| function dedupeClaudeRecords(records) { | ||
| const recordsByRequestId = new Map; | ||
| const recordsWithoutRequestId = []; | ||
| for (const record of records) { | ||
| const requestId = record.nativeMetadata?.requestId; | ||
| if (!requestId) { | ||
| recordsWithoutRequestId.push(record); | ||
| continue; | ||
| } | ||
| const key = `${record.sessionId}:${requestId}`; | ||
| const existing = recordsByRequestId.get(key); | ||
| if (!existing || shouldReplaceExistingRecord(existing, record)) { | ||
| recordsByRequestId.set(key, record); | ||
| } | ||
| } | ||
| return [...recordsByRequestId.values(), ...recordsWithoutRequestId].sort((left, right) => left.timestamp.localeCompare(right.timestamp)); | ||
| } | ||
| function normalizeClaudeUsageOptions(options) { | ||
| return { | ||
| mainThreadOnly: options?.mainThreadOnly ?? false | ||
| }; | ||
| } | ||
| function resolveClaudeHome(explicitClaudeHome) { | ||
@@ -877,3 +928,5 @@ if (explicitClaudeHome) { | ||
| } | ||
| function parseClaudeUsage(explicitClaudeHome, cache) { | ||
| function parseClaudeUsage(explicitClaudeHome, cache, options) { | ||
| const normalizedOptions = normalizeClaudeUsageOptions(options); | ||
| const cacheVariant = normalizedOptions.mainThreadOnly ? "claude-main-thread-only" : "claude-full-session"; | ||
| const claudeHome = resolveClaudeHome(explicitClaudeHome); | ||
@@ -886,3 +939,3 @@ const transcriptRoot = path2.join(claudeHome, "projects"); | ||
| const stats = fs2.statSync(filePath); | ||
| const cached = cache?.get(filePath, "claude", stats.mtimeMs, stats.size); | ||
| const cached = cache?.get(filePath, "claude", stats.mtimeMs, stats.size, cacheVariant); | ||
| if (cached) { | ||
@@ -892,4 +945,4 @@ records.push(...cached.records); | ||
| } | ||
| const parsedRecords = parseUsageFile(filePath, transcriptRoot); | ||
| cache?.set(filePath, "claude", stats.mtimeMs, stats.size, parsedRecords, null); | ||
| const parsedRecords = parseUsageFile(filePath, transcriptRoot, normalizedOptions); | ||
| cache?.set(filePath, "claude", stats.mtimeMs, stats.size, parsedRecords, null, cacheVariant); | ||
| records.push(...parsedRecords); | ||
@@ -905,7 +958,7 @@ } | ||
| }, | ||
| records | ||
| records: dedupeClaudeRecords(records) | ||
| }; | ||
| } | ||
| function withClaudeUsage(data, explicitClaudeHome, cache) { | ||
| const parsed = parseClaudeUsage(explicitClaudeHome, cache); | ||
| function withClaudeUsage(data, explicitClaudeHome, cache, options) { | ||
| const parsed = parseClaudeUsage(explicitClaudeHome, cache, options); | ||
| return { | ||
@@ -1143,3 +1196,6 @@ ...data, | ||
| if (options.source === "all" || options.source === "claude") { | ||
| data = withClaudeUsage(data, options.claudeHome, cache); | ||
| const claudeOptions = { | ||
| mainThreadOnly: options.mainThreadOnly | ||
| }; | ||
| data = withClaudeUsage(data, options.claudeHome, cache, claudeOptions); | ||
| } | ||
@@ -1276,2 +1332,22 @@ if (options.source === "all" || options.source === "codex") { | ||
| } | ||
| function isSubagentSessionRelativeDir(sessionRelativeDir) { | ||
| return sessionRelativeDir.split(/[\\/]/).includes("subagents"); | ||
| } | ||
| function pickPreferredSessionRelativeDir(current, next) { | ||
| if (!next) { | ||
| return current; | ||
| } | ||
| if (!current) { | ||
| return next; | ||
| } | ||
| const currentIsSubagent = isSubagentSessionRelativeDir(current); | ||
| const nextIsSubagent = isSubagentSessionRelativeDir(next); | ||
| if (currentIsSubagent !== nextIsSubagent) { | ||
| return nextIsSubagent ? current : next; | ||
| } | ||
| if (current.length !== next.length) { | ||
| return next.length < current.length ? next : current; | ||
| } | ||
| return next.localeCompare(current) < 0 ? next : current; | ||
| } | ||
| function filterRecords(records, timezone, since, until, sessionFilter) { | ||
@@ -1332,3 +1408,3 @@ return records.filter((record) => { | ||
| accumulator.sessionShortId = record.sessionShortId; | ||
| accumulator.sessionRelativeDir = record.sessionRelativeDir; | ||
| accumulator.sessionRelativeDir = pickPreferredSessionRelativeDir(accumulator.sessionRelativeDir, record.sessionRelativeDir); | ||
| } | ||
@@ -1469,2 +1545,3 @@ applyRecord(accumulator, record); | ||
| --codex-home <path> Override Codex home for session discovery | ||
| --main-thread-only Claude only: exclude sidechains and subagents | ||
| --format <plain|json> Statusline output format (default: plain) | ||
@@ -1516,2 +1593,3 @@ --help Show this message | ||
| let breakdown = false; | ||
| let mainThreadOnly = false; | ||
| let order = "asc"; | ||
@@ -1595,2 +1673,5 @@ let since; | ||
| break; | ||
| case "--main-thread-only": | ||
| mainThreadOnly = true; | ||
| break; | ||
| case "--order": { | ||
@@ -1644,2 +1725,3 @@ const value = requireValue(args, index, "--order"); | ||
| locale, | ||
| mainThreadOnly, | ||
| order, | ||
@@ -1659,2 +1741,3 @@ period, | ||
| codexHome: options.codexHome, | ||
| mainThreadOnly: options.mainThreadOnly, | ||
| source: options.source | ||
@@ -1661,0 +1744,0 @@ }); |
+1
-1
| { | ||
| "name": "tkusage", | ||
| "version": "0.1.4", | ||
| "version": "0.1.5", | ||
| "description": "Unified local usage and cost estimation CLI for Claude Code and Codex", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+4
-1
@@ -69,2 +69,3 @@ # tkusage | ||
| --codex-home /path/to/.codex | ||
| --main-thread-only # Claude only: exclude sidechains/subagents | ||
| --format plain|json # statusline only | ||
@@ -78,3 +79,3 @@ ``` | ||
| Selection: all | ||
| Pricing: Estimated USD using embedded Claude and Codex token pricing. Not a vendor billing statement. | ||
| Pricing: Estimated USD using embedded Claude and Codex token pricing. Claude totals include sidechains/subagents by default unless --main-thread-only is set. Not a vendor billing statement. | ||
| Tokens: Input = total prompt input, Cached = cached subset of input, Output includes reasoning, Total = Input + Output. | ||
@@ -107,2 +108,3 @@ | ||
| - `Cost` is an estimated USD equivalent derived from embedded token pricing tables | ||
| - Claude reports include sidechains/subagents by default; use `--main-thread-only` to restore the older main-transcript-only view | ||
| - It is not your Anthropic invoice | ||
@@ -118,2 +120,3 @@ - It is not your ChatGPT subscription or Codex credits statement | ||
| - Claude usage is deduplicated by `requestId` because transcript logs can repeat usage events | ||
| - Claude request deduping also collapses duplicate request IDs that appear in both main and subagent transcripts | ||
| - Codex usage is reconstructed from cumulative token snapshots, so repeated snapshots do not double count | ||
@@ -120,0 +123,0 @@ - The first run may take a while on large session folders because it builds the local cache |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
67333
5.89%1751
4.98%132
2.33%