Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/lib/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
42 changes: 34 additions & 8 deletions packages/cli/src/lib/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>();
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) {
Expand Down
126 changes: 119 additions & 7 deletions packages/cli/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);

Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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/);
Expand Down Expand Up @@ -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/);
Expand Down Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 }> }>;
Expand Down Expand Up @@ -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 }> }>;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/output-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down