diff --git a/README.md b/README.md index 2f457fd..714a407 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,8 @@ Model names are normalized to remove a trailing date suffix like `-20251101`. ## Data locations -- Claude Code: `$CLAUDE_CONFIG_DIR/*/projects` (comma-separated dirs) or defaults `~/.config/claude/projects` and `~/.claude/projects` -- Codex: `$CODEX_HOME/sessions` or `~/.codex/sessions` +- Claude Code: `$CLAUDE_CONFIG_DIR/*/projects` (comma-separated dirs) or defaults `~/.config/claude/projects`, `~/.claude/projects`, and on macOS `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/projects` +- Codex: `$CODEX_HOME/sessions` or `~/.codex/sessions`, plus on macOS `~/Library/Developer/Xcode/CodingAssistant/codex/sessions` - Cursor: reads `cursorAuth/accessToken` and `cursorAuth/refreshToken` from `$CURSOR_STATE_DB_PATH`, `$CURSOR_CONFIG_DIR/User/globalStorage/state.vscdb`, `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` (macOS), `%APPDATA%/Cursor/User/globalStorage/state.vscdb` (Windows), or `~/.config/Cursor/User/globalStorage/state.vscdb` (Linux), then loads usage from Cursor's CSV export endpoint - Gemini CLI: `$GEMINI_CONFIG_DIR/tmp/**/chats/session-*.json` or `~/.gemini/tmp/**/chats/session-*.json` - Open Code: prefers `$OPENCODE_DATA_DIR/opencode.db` or `~/.local/share/opencode/opencode.db`, and falls back to `$OPENCODE_DATA_DIR/storage/message` or `~/.local/share/opencode/storage/message` diff --git a/packages/cli/README.md b/packages/cli/README.md index 8ba2174..211bafb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -114,10 +114,10 @@ npx slopmeter --dark --format svg --output ./out/heatmap-dark.svg ## Data locations -- Claude Code: `$CLAUDE_CONFIG_DIR/*/projects` or `~/.config/claude/projects`, `~/.claude/projects` +- Claude Code: `$CLAUDE_CONFIG_DIR/*/projects` or `~/.config/claude/projects`, `~/.claude/projects`, and on macOS `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/projects` - Older Claude Code layouts: falls back to `$CLAUDE_CONFIG_DIR/stats-cache.json`, `~/.config/claude/stats-cache.json`, or `~/.claude/stats-cache.json` for days not present in project logs - Earliest Claude Code activity fallback: uses `$CLAUDE_CONFIG_DIR/history.jsonl`, `~/.config/claude/history.jsonl`, or `~/.claude/history.jsonl` to mark activity-only days when token totals are unavailable -- Codex: `$CODEX_HOME/sessions` or `~/.codex/sessions` +- Codex: `$CODEX_HOME/sessions` or `~/.codex/sessions`, plus on macOS `~/Library/Developer/Xcode/CodingAssistant/codex/sessions` - Cursor: reads `cursorAuth/accessToken` and `cursorAuth/refreshToken` from `$CURSOR_STATE_DB_PATH`, `$CURSOR_CONFIG_DIR/User/globalStorage/state.vscdb`, `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` (macOS), `%APPDATA%/Cursor/User/globalStorage/state.vscdb` (Windows), or `~/.config/Cursor/User/globalStorage/state.vscdb` (Linux), then loads usage from Cursor's CSV export endpoint - Gemini CLI: `$GEMINI_CONFIG_DIR/tmp/**/chats/session-*.json` or `~/.gemini/tmp/**/chats/session-*.json` - Open Code: prefers `$OPENCODE_DATA_DIR/opencode.db` or `~/.local/share/opencode/opencode.db`, and falls back to `$OPENCODE_DATA_DIR/storage/message` or `~/.local/share/opencode/storage/message` diff --git a/packages/cli/src/lib/claude-code.ts b/packages/cli/src/lib/claude-code.ts index 4f96698..9a5a48d 100644 --- a/packages/cli/src/lib/claude-code.ts +++ b/packages/cli/src/lib/claude-code.ts @@ -100,6 +100,12 @@ function getClaudeConfigPaths() { const defaults = [join(xdgConfigHome, "claude"), join(homedir(), ".claude")]; + if (process.platform === "darwin") { + defaults.push( + join(homedir(), "Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig"), + ); + } + const envPaths = (process.env[CLAUDE_CONFIG_DIR_ENV] ?? "").trim(); const envResolved = diff --git a/packages/cli/src/lib/codex.ts b/packages/cli/src/lib/codex.ts index 287ab55..bb4476a 100644 --- a/packages/cli/src/lib/codex.ts +++ b/packages/cli/src/lib/codex.ts @@ -23,7 +23,10 @@ import { readJsonlRecords, runWithConcurrency, } from "./utils"; + const CLASSIFICATION_PREFIX_BYTES = 32 * 1024; +const CODEX_HOME_ENV = "CODEX_HOME"; +const CODEX_SESSIONS_DIR_NAME = "sessions"; interface CodexRawUsage { input_tokens?: number; @@ -204,20 +207,43 @@ function extractCodexModel(payload?: CodexEventPayload) { return undefined; } -function getCodexHome() { - return process.env.CODEX_HOME?.trim() - ? resolve(process.env.CODEX_HOME) - : join(homedir(), ".codex"); +function getCodexSessionDirs() { + const dirs: string[] = []; + const seen = new Set(); + const envHome = process.env[CODEX_HOME_ENV]?.trim(); + const defaultHomes = [envHome ? resolve(envHome) : join(homedir(), ".codex")]; + + if (process.platform === "darwin") { + defaultHomes.push( + join(homedir(), "Library/Developer/Xcode/CodingAssistant/codex"), + ); + } + + for (const basePath of defaultHomes) { + const sessionsDir = join(basePath, CODEX_SESSIONS_DIR_NAME); + + if (!seen.has(sessionsDir)) { + seen.add(sessionsDir); + dirs.push(sessionsDir); + } + } + + return dirs; } async function getCodexFiles() { - const codexHome = getCodexHome(); - - return listFilesRecursive(join(codexHome, "sessions"), ".jsonl"); + const sessionDirs = getCodexSessionDirs(); + const files = ( + await Promise.all( + sessionDirs.map((sessionDir) => listFilesRecursive(sessionDir, ".jsonl")), + ) + ).flat(); + + return files.sort((left, right) => left.localeCompare(right)); } export function isCodexAvailable() { - return existsSync(join(getCodexHome(), "sessions")); + return getCodexSessionDirs().some((sessionDir) => existsSync(sessionDir)); } function readJsonString(source: string, start: number) { diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 1dccd65..14dbcd7 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -467,7 +467,7 @@ test("--codex only loads Codex and only reports Codex availability", async (t) = ); assert.equal(result.code, 0, result.stderr || result.stdout); - assert.match(result.stdout, /Codex found/); + assert.match(result.stdout, /Codex available/); assert.doesNotMatch(result.stdout, /Claude code/); assert.doesNotMatch(result.stdout, /Open Code/); @@ -702,6 +702,54 @@ test("Codex advances the cumulative baseline across last-usage-only records", as assert.equal(payload.providers[0]?.daily[0]?.total, 145); }); +test( + "Codex automatically includes Xcode Coding Assistant sessions on macOS", + { skip: process.platform !== "darwin" }, + async (t) => { + const workspace = createTempWorkspace("codex-xcode-default"); + + t.after(() => { + rmSync(workspace, { recursive: true, force: true }); + }); + + const homeDir = join(workspace, "home"); + const outputPath = join(workspace, "out.json"); + const xcodeCodexDir = join( + homeDir, + "Library/Developer/Xcode/CodingAssistant/codex", + ); + + writeJsonlFile(join(xcodeCodexDir, "sessions", "session.jsonl"), [ + codexTurnContext("gpt-5.4"), + codexTokenCount({ input: 9, output: 6, total: 15 }), + ]); + + const result = await runCli( + ["--codex", "--format", "json", "--output", outputPath], + { + HOME: homeDir, + }, + ); + + assert.equal(result.code, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Codex available/); + + const payload = JSON.parse(readFileSync(outputPath, "utf8")) as { + providers: Array<{ + provider: string; + daily: Array<{ total: number; breakdown: Array<{ name: string }> }>; + }>; + }; + + assert.deepEqual( + payload.providers.map((provider) => provider.provider), + ["codex"], + ); + assert.equal(payload.providers[0]?.daily[0]?.total, 15); + assert.equal(payload.providers[0]?.daily[0]?.breakdown[0]?.name, "gpt-5.4"); + }, +); + test("--pi only loads Pi Coding Agent and ignores oversized irrelevant session records", async (t) => { const workspace = createTempWorkspace("pi-only"); @@ -740,7 +788,7 @@ test("--pi only loads Pi Coding Agent and ignores oversized irrelevant session r ); assert.equal(result.code, 0, result.stderr || result.stdout); - assert.match(result.stdout, /Pi Coding Agent found/); + assert.match(result.stdout, /Pi Coding Agent available/); assert.doesNotMatch(result.stdout, /Claude code/); assert.doesNotMatch(result.stdout, /Codex/); assert.doesNotMatch(result.stdout, /Open Code/); @@ -806,7 +854,7 @@ test("--gemini only loads Gemini CLI and only reports Gemini availability", asyn ); assert.equal(result.code, 0, result.stderr || result.stdout); - assert.match(result.stdout, /Gemini CLI found/); + assert.match(result.stdout, /Gemini CLI available/); assert.doesNotMatch(result.stdout, /Claude code/); assert.doesNotMatch(result.stdout, /Codex/); assert.doesNotMatch(result.stdout, /Open Code/); @@ -1088,8 +1136,8 @@ test("Gemini CLI participates in multi-provider output order", async (t) => { ); assert.equal(result.code, 0, result.stderr || result.stdout); - assert.match(result.stdout, /Gemini CLI found/); - assert.match(result.stdout, /Pi Coding Agent found/); + assert.match(result.stdout, /Gemini CLI available/); + assert.match(result.stdout, /Pi Coding Agent available/); const payload = JSON.parse(readFileSync(outputPath, "utf8")) as { providers: Array<{ provider: string }>; @@ -1215,6 +1263,70 @@ test("Claude JSONL streaming preserves usage results across multiple files", asy assert.equal(payload.providers[0]?.daily[0]?.total, 25); }); +test( + "Claude automatically includes Xcode Coding Assistant sessions on macOS", + { skip: process.platform !== "darwin" }, + async (t) => { + const workspace = createTempWorkspace("claude-xcode-default"); + + t.after(() => { + rmSync(workspace, { recursive: true, force: true }); + }); + + const homeDir = join(workspace, "home"); + const outputPath = join(workspace, "out.json"); + const xcodeClaudeConfig = join( + homeDir, + "Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig", + ); + + writeJsonlFile( + join( + xcodeClaudeConfig, + "projects", + "sample-project", + "session.jsonl", + ), + [ + claudeEntry({ + messageId: "m-xcode-1", + requestId: "r-xcode-1", + model: "claude-opus-4-6", + input: 8, + output: 7, + }), + ], + ); + + const result = await runCli( + ["--claude", "--format", "json", "--output", outputPath], + { + HOME: homeDir, + }, + ); + + assert.equal(result.code, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Claude code available/); + + const payload = JSON.parse(readFileSync(outputPath, "utf8")) as { + providers: Array<{ + provider: string; + daily: Array<{ total: number; breakdown: Array<{ name: string }> }>; + }>; + }; + + assert.deepEqual( + payload.providers.map((provider) => provider.provider), + ["claude"], + ); + assert.equal(payload.providers[0]?.daily[0]?.total, 15); + assert.equal( + payload.providers[0]?.daily[0]?.breakdown[0]?.name, + "claude-opus-4-6", + ); + }, +); + test("Cursor streams CSV rows without buffering the full export", async () => { const encoder = new TextEncoder(); const response = new Response( @@ -1551,7 +1663,7 @@ test("OpenCode reads the legacy file-backed message layout", async (t) => { ); assert.equal(result.code, 0, result.stderr || result.stdout); - assert.match(result.stdout, /Open Code found/); + assert.match(result.stdout, /Open Code available/); const payload = JSON.parse(readFileSync(outputPath, "utf8")) as { providers: Array<{ provider: string; daily: Array<{ total: number }> }>; @@ -1599,7 +1711,7 @@ test("OpenCode prefers the SQLite message store when opencode.db exists", async ); assert.equal(result.code, 0, result.stderr || result.stdout); - assert.match(result.stdout, /Open Code found/); + assert.match(result.stdout, /Open Code available/); const payload = JSON.parse(readFileSync(outputPath, "utf8")) as { providers: Array<{ provider: string; daily: Array<{ total: number }> }>; diff --git a/packages/cli/test/output-path.test.ts b/packages/cli/test/output-path.test.ts index f3a9548..7c788b3 100644 --- a/packages/cli/test/output-path.test.ts +++ b/packages/cli/test/output-path.test.ts @@ -3,7 +3,7 @@ import test from "node:test"; import { getDefaultOutputPath, getDefaultOutputSuffix, -} from "../src/output-path.ts"; +} from "../src/output-path"; function createValues(overrides?: Partial<{ all: boolean;