mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(analytics): add native codex and droid collectors
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# CCS Codebase Summary
|
||||
|
||||
Last Updated: 2026-04-07
|
||||
Last Updated: 2026-04-26
|
||||
|
||||
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, and native Codex runtime target support.
|
||||
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, native Codex runtime target support, and native Codex/Droid usage collectors.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -212,10 +212,12 @@ src/
|
||||
│ └── [more routes...]
|
||||
├── health/ # Health check system
|
||||
│ └── index.ts
|
||||
├── usage/ # Usage analytics module
|
||||
├── usage/ # Usage analytics module (default Claude, CCS instances, native Codex/Droid, CLIProxy snapshots)
|
||||
│ ├── index.ts
|
||||
│ ├── handlers.ts # Request handlers (633 lines)
|
||||
│ ├── aggregator.ts # Data aggregation (538 lines)
|
||||
│ ├── codex-native-usage-collector.ts # Native Codex rollout JSONL collector
|
||||
│ ├── droid-native-usage-collector.ts # Native Droid SQLite collector
|
||||
│ └── data-aggregator.ts
|
||||
├── services/ # Shared services
|
||||
│ └── index.ts
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { loadAllUsageData } from './data-aggregator';
|
||||
import {
|
||||
aggregateDailyUsage,
|
||||
aggregateHourlyUsage,
|
||||
aggregateMonthlyUsage,
|
||||
aggregateSessionUsage,
|
||||
loadAllUsageData,
|
||||
} from './data-aggregator';
|
||||
import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types';
|
||||
import {
|
||||
readDiskCache,
|
||||
@@ -26,6 +32,8 @@ import {
|
||||
stopCliproxySync,
|
||||
syncCliproxyUsage,
|
||||
} from './cliproxy-usage-syncer';
|
||||
import { scanCodexNativeUsageEntries } from './codex-native-usage-collector';
|
||||
import { scanDroidNativeUsageEntries } from './droid-native-usage-collector';
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Instance Support - Aggregate usage from CCS profiles
|
||||
@@ -383,6 +391,32 @@ async function refreshFromSource(): Promise<{
|
||||
console.log(info(`Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`));
|
||||
}
|
||||
|
||||
try {
|
||||
const codexEntries = await scanCodexNativeUsageEntries();
|
||||
if (codexEntries.length > 0) {
|
||||
allDailySources.push(aggregateDailyUsage(codexEntries, 'codex-native'));
|
||||
allHourlySources.push(aggregateHourlyUsage(codexEntries, 'codex-native'));
|
||||
allMonthlySources.push(aggregateMonthlyUsage(codexEntries, 'codex-native'));
|
||||
allSessionSources.push(aggregateSessionUsage(codexEntries, 'codex-native'));
|
||||
console.log(info(`Included native Codex usage data (${codexEntries.length} event(s))`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(fail(`Failed to load native Codex usage data: ${err}`));
|
||||
}
|
||||
|
||||
try {
|
||||
const droidEntries = await scanDroidNativeUsageEntries();
|
||||
if (droidEntries.length > 0) {
|
||||
allDailySources.push(aggregateDailyUsage(droidEntries, 'droid-native'));
|
||||
allHourlySources.push(aggregateHourlyUsage(droidEntries, 'droid-native'));
|
||||
allMonthlySources.push(aggregateMonthlyUsage(droidEntries, 'droid-native'));
|
||||
allSessionSources.push(aggregateSessionUsage(droidEntries, 'droid-native'));
|
||||
console.log(info(`Included native Droid usage data (${droidEntries.length} event(s))`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(fail(`Failed to load native Droid usage data: ${err}`));
|
||||
}
|
||||
|
||||
// Load CLIProxy usage data (from local snapshot cache)
|
||||
try {
|
||||
const cliproxyData = await loadCachedCliproxyData();
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as readline from 'readline';
|
||||
import type { RawUsageEntry } from '../jsonl-parser';
|
||||
import { resolveCodexConfigPaths } from '../services/codex-dashboard-service';
|
||||
|
||||
interface CodexNativeUsageCollectorOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
includeCliproxySessions?: boolean;
|
||||
}
|
||||
|
||||
interface CodexTokenSnapshot {
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function hasUsage(snapshot: CodexTokenSnapshot): boolean {
|
||||
return snapshot.inputTokens > 0 || snapshot.cacheReadTokens > 0 || snapshot.outputTokens > 0;
|
||||
}
|
||||
|
||||
function normalizeTokenSnapshot(value: unknown): CodexTokenSnapshot | null {
|
||||
if (!isObject(value)) return null;
|
||||
return {
|
||||
inputTokens: asNumber(value.input_tokens),
|
||||
cacheReadTokens: asNumber(value.cached_input_tokens),
|
||||
outputTokens: asNumber(value.output_tokens) + asNumber(value.reasoning_output_tokens),
|
||||
};
|
||||
}
|
||||
|
||||
function subtractSnapshots(
|
||||
current: CodexTokenSnapshot,
|
||||
previous: CodexTokenSnapshot
|
||||
): CodexTokenSnapshot {
|
||||
return {
|
||||
inputTokens: Math.max(0, current.inputTokens - previous.inputTokens),
|
||||
cacheReadTokens: Math.max(0, current.cacheReadTokens - previous.cacheReadTokens),
|
||||
outputTokens: Math.max(0, current.outputTokens - previous.outputTokens),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotsEqual(left: CodexTokenSnapshot, right: CodexTokenSnapshot): boolean {
|
||||
return (
|
||||
left.inputTokens === right.inputTokens &&
|
||||
left.cacheReadTokens === right.cacheReadTokens &&
|
||||
left.outputTokens === right.outputTokens
|
||||
);
|
||||
}
|
||||
|
||||
async function collectRolloutFiles(dir: string): Promise<string[]> {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
|
||||
const files: string[] = [];
|
||||
for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectRolloutFiles(entryPath)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
async function parseRolloutFile(
|
||||
filePath: string,
|
||||
includeCliproxySessions: boolean
|
||||
): Promise<RawUsageEntry[]> {
|
||||
const entries: RawUsageEntry[] = [];
|
||||
let sessionId = '';
|
||||
let projectPath = '';
|
||||
let version: string | undefined;
|
||||
let modelProvider: string | undefined;
|
||||
let model = 'unknown-codex-model';
|
||||
let previousTotal: CodexTokenSnapshot | null = null;
|
||||
|
||||
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
try {
|
||||
for await (const line of reader) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.type === 'session_meta' && isObject(parsed.payload)) {
|
||||
sessionId = asString(parsed.payload.id) ?? sessionId;
|
||||
projectPath = asString(parsed.payload.cwd) ?? projectPath;
|
||||
version = asString(parsed.payload.cli_version) ?? version;
|
||||
modelProvider = asString(parsed.payload.model_provider) ?? modelProvider;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.type === 'turn_context' && isObject(parsed.payload)) {
|
||||
model = asString(parsed.payload.model) ?? model;
|
||||
projectPath = asString(parsed.payload.cwd) ?? projectPath;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.type !== 'event_msg' ||
|
||||
!isObject(parsed.payload) ||
|
||||
parsed.payload.type !== 'token_count' ||
|
||||
!sessionId ||
|
||||
(modelProvider === 'cliproxy' && !includeCliproxySessions)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalSnapshot = normalizeTokenSnapshot(
|
||||
parsed.payload.info && isObject(parsed.payload.info)
|
||||
? (parsed.payload.info as Record<string, unknown>).total_token_usage
|
||||
: null
|
||||
);
|
||||
const lastSnapshot = normalizeTokenSnapshot(
|
||||
parsed.payload.info && isObject(parsed.payload.info)
|
||||
? (parsed.payload.info as Record<string, unknown>).last_token_usage
|
||||
: null
|
||||
);
|
||||
|
||||
if (!totalSnapshot) continue;
|
||||
if (previousTotal && snapshotsEqual(totalSnapshot, previousTotal)) continue;
|
||||
|
||||
const delta =
|
||||
previousTotal === null
|
||||
? lastSnapshot && hasUsage(lastSnapshot)
|
||||
? lastSnapshot
|
||||
: totalSnapshot
|
||||
: subtractSnapshots(totalSnapshot, previousTotal);
|
||||
previousTotal = totalSnapshot;
|
||||
|
||||
if (!hasUsage(delta)) continue;
|
||||
|
||||
const timestamp = asString(parsed.timestamp);
|
||||
if (!timestamp) continue;
|
||||
|
||||
entries.push({
|
||||
inputTokens: delta.inputTokens,
|
||||
outputTokens: delta.outputTokens,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: delta.cacheReadTokens,
|
||||
model,
|
||||
sessionId,
|
||||
timestamp,
|
||||
projectPath: projectPath || '/',
|
||||
version,
|
||||
target: 'codex',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
reader.close();
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function scanCodexNativeUsageEntries(
|
||||
options: CodexNativeUsageCollectorOptions = {}
|
||||
): Promise<RawUsageEntry[]> {
|
||||
const { baseDir } = resolveCodexConfigPaths({
|
||||
env: options.env,
|
||||
homeDir: options.homeDir,
|
||||
});
|
||||
const rolloutFiles = await collectRolloutFiles(path.join(baseDir, 'sessions'));
|
||||
const entries: RawUsageEntry[] = [];
|
||||
|
||||
for (const filePath of rolloutFiles) {
|
||||
entries.push(...(await parseRolloutFile(filePath, options.includeCliproxySessions === true)));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { RawUsageEntry } from '../jsonl-parser';
|
||||
import { resolveDroidConfigPaths } from '../services/droid-dashboard-service';
|
||||
import { querySqliteJson } from './sqlite-cli';
|
||||
|
||||
export type DroidSqliteQuery = typeof querySqliteJson;
|
||||
|
||||
interface DroidNativeUsageCollectorOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
includeCcsManagedSessions?: boolean;
|
||||
querySqliteJson?: DroidSqliteQuery;
|
||||
}
|
||||
|
||||
interface DroidSessionMetadata {
|
||||
model: string;
|
||||
projectPath: string;
|
||||
rawSelector: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
|
||||
}
|
||||
|
||||
function isCcsManagedSelector(selector: string): boolean {
|
||||
return selector.startsWith('custom:CCS-') || selector.startsWith('custom:ccs-');
|
||||
}
|
||||
|
||||
function buildSelectorAlias(displayName: string, index: number): string {
|
||||
return `${displayName.trim().replace(/\s+/g, '-')}-${index}`;
|
||||
}
|
||||
|
||||
function normalizeCustomModels(settings: Record<string, unknown>): Array<Record<string, unknown>> {
|
||||
const raw = settings.customModels ?? settings.custom_models;
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.filter((entry): entry is Record<string, unknown> => isObject(entry));
|
||||
}
|
||||
if (isObject(raw)) {
|
||||
return Object.values(raw).filter((entry): entry is Record<string, unknown> => isObject(entry));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildCustomModelMap(settingsPath: string): Map<string, string> {
|
||||
const selectors = new Map<string, string>();
|
||||
if (!fs.existsSync(settingsPath)) return selectors;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
if (!isObject(parsed)) return selectors;
|
||||
|
||||
for (const [index, entry] of normalizeCustomModels(parsed).entries()) {
|
||||
const displayName = asString(entry.displayName ?? entry.model_display_name);
|
||||
const model = asString(entry.model);
|
||||
if (!displayName || !model) continue;
|
||||
selectors.set(`custom:${buildSelectorAlias(displayName, index)}`, model);
|
||||
}
|
||||
} catch {
|
||||
return selectors;
|
||||
}
|
||||
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function collectSessionFiles(dir: string, suffix: string, results: string[] = []): string[] {
|
||||
if (!fs.existsSync(dir)) return results;
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectSessionFiles(entryPath, suffix, results);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(suffix)) {
|
||||
results.push(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function readSessionStartMetadata(
|
||||
filePath: string
|
||||
): { projectPath: string; version?: string } | null {
|
||||
try {
|
||||
const firstLine = fs.readFileSync(filePath, 'utf8').split('\n')[0];
|
||||
const parsed = JSON.parse(firstLine);
|
||||
if (parsed?.type !== 'session_start') return null;
|
||||
return {
|
||||
projectPath: asString(parsed.cwd) ?? '/',
|
||||
version:
|
||||
typeof parsed.version === 'number'
|
||||
? String(parsed.version)
|
||||
: (asString(parsed.version) ?? undefined),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadSessionMetadata(factoryDir: string): Map<string, DroidSessionMetadata> {
|
||||
const metadata = new Map<string, DroidSessionMetadata>();
|
||||
const selectorMap = buildCustomModelMap(path.join(factoryDir, 'settings.json'));
|
||||
const sessionsDir = path.join(factoryDir, 'sessions');
|
||||
|
||||
for (const settingsPath of collectSessionFiles(sessionsDir, '.settings.json')) {
|
||||
const sessionId = path.basename(settingsPath, '.settings.json');
|
||||
const jsonlPath = settingsPath.replace(/\.settings\.json$/, '.jsonl');
|
||||
const start = readSessionStartMetadata(jsonlPath);
|
||||
if (!start) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const rawSelector = asString(parsed?.model) ?? 'unknown-droid-model';
|
||||
metadata.set(sessionId, {
|
||||
model: selectorMap.get(rawSelector) ?? rawSelector,
|
||||
projectPath: start.projectPath,
|
||||
rawSelector,
|
||||
version: start.version,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export async function scanDroidNativeUsageEntries(
|
||||
options: DroidNativeUsageCollectorOptions = {}
|
||||
): Promise<RawUsageEntry[]> {
|
||||
const query = options.querySqliteJson ?? querySqliteJson;
|
||||
const { settingsPath } = resolveDroidConfigPaths({
|
||||
env: options.env,
|
||||
homeDir: options.homeDir,
|
||||
});
|
||||
const factoryDir = path.dirname(settingsPath);
|
||||
const costsDbPath = path.join(factoryDir, 'costs.db');
|
||||
if (!fs.existsSync(costsDbPath)) return [];
|
||||
|
||||
const rows =
|
||||
(await query(
|
||||
costsDbPath,
|
||||
'SELECT session_id, timestamp, input_tokens, output_tokens FROM costs ORDER BY timestamp ASC;'
|
||||
)) ?? [];
|
||||
if (!rows.length) return [];
|
||||
|
||||
const metadata = loadSessionMetadata(factoryDir);
|
||||
const entries: RawUsageEntry[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!isObject(row)) continue;
|
||||
const sessionId = asString(row.session_id);
|
||||
const timestamp = asString(row.timestamp);
|
||||
const inputTokens = asNumber(row.input_tokens);
|
||||
const outputTokens = asNumber(row.output_tokens);
|
||||
if (!sessionId || !timestamp || inputTokens === null || outputTokens === null) continue;
|
||||
|
||||
const session = metadata.get(sessionId);
|
||||
if (
|
||||
session &&
|
||||
!options.includeCcsManagedSessions &&
|
||||
isCcsManagedSelector(session.rawSelector)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
model: session?.model ?? 'unknown-droid-model',
|
||||
sessionId,
|
||||
timestamp,
|
||||
projectPath: session?.projectPath ?? '/',
|
||||
version: session?.version,
|
||||
target: 'droid',
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { execFile } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SQLITE_JSON_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
export type SqliteJsonRow = Record<string, unknown>;
|
||||
|
||||
function isCommandMissing(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const nodeError = error as Error & { code?: string };
|
||||
return nodeError.code === 'ENOENT' || /not found/i.test(nodeError.message);
|
||||
}
|
||||
|
||||
export async function querySqliteJson(
|
||||
dbPath: string,
|
||||
sql: string
|
||||
): Promise<SqliteJsonRow[] | null> {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('sqlite3', ['-json', dbPath, sql], {
|
||||
maxBuffer: SQLITE_JSON_MAX_BUFFER,
|
||||
});
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return Array.isArray(parsed) ? (parsed as SqliteJsonRow[]) : [];
|
||||
} catch (error) {
|
||||
if (isCommandMissing(error)) {
|
||||
return null;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { scanCodexNativeUsageEntries } from '../../../src/web-server/usage/codex-native-usage-collector';
|
||||
|
||||
function writeCodexRollout(
|
||||
baseDir: string,
|
||||
options: {
|
||||
sessionId?: string;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
} = {}
|
||||
): void {
|
||||
const sessionId = options.sessionId ?? 'codex-session-1';
|
||||
const rolloutDir = path.join(baseDir, 'sessions', '2026', '03', '02');
|
||||
const rolloutPath = path.join(rolloutDir, `rollout-${sessionId}.jsonl`);
|
||||
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: sessionId,
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
cwd: options.cwd ?? '/tmp/codex-project',
|
||||
cli_version: '0.126.0',
|
||||
source: 'cli',
|
||||
model_provider: options.modelProvider ?? 'openai',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:00:01.000Z',
|
||||
type: 'turn_context',
|
||||
payload: {
|
||||
turn_id: 'turn-1',
|
||||
cwd: options.cwd ?? '/tmp/codex-project',
|
||||
model: options.model ?? 'gpt-5',
|
||||
effort: 'high',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:00:02.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: null,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:05:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 20,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 2,
|
||||
total_tokens: 127,
|
||||
},
|
||||
last_token_usage: {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 20,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 2,
|
||||
total_tokens: 127,
|
||||
},
|
||||
model_context_window: 200000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:05:30.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 20,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 2,
|
||||
total_tokens: 127,
|
||||
},
|
||||
last_token_usage: {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 20,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 2,
|
||||
total_tokens: 127,
|
||||
},
|
||||
model_context_window: 200000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:10:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 150,
|
||||
cached_input_tokens: 30,
|
||||
output_tokens: 10,
|
||||
reasoning_output_tokens: 3,
|
||||
total_tokens: 193,
|
||||
},
|
||||
last_token_usage: {
|
||||
input_tokens: 50,
|
||||
cached_input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 1,
|
||||
total_tokens: 66,
|
||||
},
|
||||
model_context_window: 200000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
fs.mkdirSync(rolloutDir, { recursive: true });
|
||||
fs.writeFileSync(rolloutPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
}
|
||||
|
||||
describe('codex native usage collector', () => {
|
||||
let tempRoot = '';
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-native-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('parses token_count events into raw usage entries and suppresses duplicates', async () => {
|
||||
writeCodexRollout(tempRoot);
|
||||
|
||||
const entries = await scanCodexNativeUsageEntries({
|
||||
env: { CODEX_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toMatchObject({
|
||||
sessionId: 'codex-session-1',
|
||||
projectPath: '/tmp/codex-project',
|
||||
model: 'gpt-5',
|
||||
version: '0.126.0',
|
||||
target: 'codex',
|
||||
inputTokens: 100,
|
||||
cacheReadTokens: 20,
|
||||
outputTokens: 7,
|
||||
});
|
||||
expect(entries[1]).toMatchObject({
|
||||
inputTokens: 50,
|
||||
cacheReadTokens: 10,
|
||||
outputTokens: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips cliproxy-backed codex sessions by default to avoid double counting', async () => {
|
||||
writeCodexRollout(tempRoot, { modelProvider: 'cliproxy' });
|
||||
|
||||
const entries = await scanCodexNativeUsageEntries({
|
||||
env: { CODEX_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
scanDroidNativeUsageEntries,
|
||||
type DroidSqliteQuery,
|
||||
} from '../../../src/web-server/usage/droid-native-usage-collector';
|
||||
|
||||
function writeDroidSessionFixture(
|
||||
tempRoot: string,
|
||||
options: {
|
||||
sessionId?: string;
|
||||
selector?: string;
|
||||
cwd?: string;
|
||||
} = {}
|
||||
): string {
|
||||
const sessionId = options.sessionId ?? 'droid-session-1';
|
||||
const sessionDir = path.join(tempRoot, '.factory', 'sessions', 'demo-project');
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(sessionDir, `${sessionId}.jsonl`),
|
||||
`${JSON.stringify({
|
||||
type: 'session_start',
|
||||
id: sessionId,
|
||||
version: 2,
|
||||
cwd: options.cwd ?? '/tmp/droid-project',
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(sessionDir, `${sessionId}.settings.json`),
|
||||
JSON.stringify({
|
||||
model: options.selector ?? 'custom:Demo-0',
|
||||
providerLock: 'openai',
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function writeDroidGlobalSettings(tempRoot: string): void {
|
||||
const factoryDir = path.join(tempRoot, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{
|
||||
displayName: 'Demo',
|
||||
model: 'gpt-4.1',
|
||||
provider: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
},
|
||||
{
|
||||
displayName: 'CCS codex',
|
||||
model: 'gpt-5',
|
||||
provider: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(path.join(factoryDir, 'costs.db'), '', 'utf8');
|
||||
}
|
||||
|
||||
describe('droid native usage collector', () => {
|
||||
let tempRoot = '';
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-native-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('hydrates cost rows with session metadata and resolved custom model IDs', async () => {
|
||||
writeDroidGlobalSettings(tempRoot);
|
||||
const sessionId = writeDroidSessionFixture(tempRoot);
|
||||
|
||||
const querySqliteJson: DroidSqliteQuery = async () => [
|
||||
{
|
||||
session_id: sessionId,
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
input_tokens: 120,
|
||||
output_tokens: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const entries = await scanDroidNativeUsageEntries({
|
||||
env: { CCS_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
querySqliteJson,
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
sessionId,
|
||||
projectPath: '/tmp/droid-project',
|
||||
model: 'gpt-4.1',
|
||||
inputTokens: 120,
|
||||
outputTokens: 30,
|
||||
target: 'droid',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips CCS-managed selectors to avoid double counting CLIProxy-backed Droid runs', async () => {
|
||||
writeDroidGlobalSettings(tempRoot);
|
||||
const sessionId = writeDroidSessionFixture(tempRoot, { selector: 'custom:CCS-codex-1' });
|
||||
|
||||
const querySqliteJson: DroidSqliteQuery = async () => [
|
||||
{
|
||||
session_id: sessionId,
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
input_tokens: 120,
|
||||
output_tokens: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const entries = await scanDroidNativeUsageEntries({
|
||||
env: { CCS_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
querySqliteJson,
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,12 @@ import * as path from 'path';
|
||||
let tempRoot = '';
|
||||
let ccsDir = '';
|
||||
let claudeDir = '';
|
||||
let codexDir = '';
|
||||
let aggregator: typeof import('../../../src/web-server/usage/aggregator');
|
||||
let originalCcsDir: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCodexHome: string | undefined;
|
||||
|
||||
function writeClaudeJsonlFixture(): void {
|
||||
const projectDir = path.join(claudeDir, 'projects', 'project-one');
|
||||
@@ -153,13 +155,16 @@ beforeEach(async () => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-agg-'));
|
||||
ccsDir = path.join(tempRoot, '.ccs');
|
||||
claudeDir = path.join(tempRoot, '.claude');
|
||||
codexDir = path.join(tempRoot, '.codex');
|
||||
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CCS_DIR = ccsDir;
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir;
|
||||
process.env.CODEX_HOME = codexDir;
|
||||
|
||||
writeUnifiedConfigFixture();
|
||||
writeClaudeJsonlFixture();
|
||||
@@ -191,6 +196,12 @@ afterEach(() => {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (originalCodexHome !== undefined) {
|
||||
process.env.CODEX_HOME = originalCodexHome;
|
||||
} else {
|
||||
delete process.env.CODEX_HOME;
|
||||
}
|
||||
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
let tempRoot = '';
|
||||
let ccsDir = '';
|
||||
let claudeDir = '';
|
||||
let codexDir = '';
|
||||
let aggregator: typeof import('../../../src/web-server/usage/aggregator');
|
||||
let originalCcsDir: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCodexHome: string | undefined;
|
||||
|
||||
function writeUnifiedConfigFixture(): void {
|
||||
const yaml = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants: {}
|
||||
cliproxy_server:
|
||||
local:
|
||||
port: 65534
|
||||
`;
|
||||
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8');
|
||||
}
|
||||
|
||||
function writeClaudeJsonlFixture(): void {
|
||||
const projectDir = path.join(claudeDir, 'projects', 'project-one');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
const line = JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
cwd: '/tmp/project',
|
||||
message: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(path.join(projectDir, 'usage.jsonl'), `${line}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function writeCliproxySnapshotFixture(): void {
|
||||
const snapshotDir = path.join(ccsDir, 'cache', 'cliproxy-usage');
|
||||
fs.mkdirSync(snapshotDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(snapshotDir, 'latest.json'),
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
timestamp: Date.now(),
|
||||
details: [
|
||||
{
|
||||
model: 'gemini-2.5-pro',
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
source: 'account-a',
|
||||
authIndex: '0',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheReadTokens: 5,
|
||||
requestCount: 1,
|
||||
cost: 0.2,
|
||||
failed: false,
|
||||
},
|
||||
],
|
||||
daily: [
|
||||
{
|
||||
date: '2026-03-02',
|
||||
source: 'cliproxy',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
cost: 0.2,
|
||||
totalCost: 0.2,
|
||||
modelsUsed: ['gemini-2.5-pro'],
|
||||
modelBreakdowns: [
|
||||
{
|
||||
modelName: 'gemini-2.5-pro',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
cost: 0.2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hourly: [
|
||||
{
|
||||
hour: '2026-03-02 10:00',
|
||||
source: 'cliproxy',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
cost: 0.2,
|
||||
totalCost: 0.2,
|
||||
modelsUsed: ['gemini-2.5-pro'],
|
||||
modelBreakdowns: [
|
||||
{
|
||||
modelName: 'gemini-2.5-pro',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
cost: 0.2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
monthly: [
|
||||
{
|
||||
month: '2026-03',
|
||||
source: 'cliproxy',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
totalCost: 0.2,
|
||||
modelsUsed: ['gemini-2.5-pro'],
|
||||
modelBreakdowns: [
|
||||
{
|
||||
modelName: 'gemini-2.5-pro',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
cost: 0.2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
function writeCodexFixture(modelProvider = 'openai'): void {
|
||||
const rolloutDir = path.join(codexDir, 'sessions', '2026', '03', '02');
|
||||
fs.mkdirSync(rolloutDir, { recursive: true });
|
||||
const rolloutPath = path.join(rolloutDir, 'rollout-native-codex.jsonl');
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: 'codex-session',
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
cwd: '/tmp/codex-project',
|
||||
cli_version: '0.126.0',
|
||||
source: 'cli',
|
||||
model_provider: modelProvider,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:00:01.000Z',
|
||||
type: 'turn_context',
|
||||
payload: {
|
||||
turn_id: 'turn-1',
|
||||
cwd: '/tmp/codex-project',
|
||||
model: 'gpt-5',
|
||||
effort: 'high',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:05:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 30,
|
||||
cached_input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
reasoning_output_tokens: 1,
|
||||
total_tokens: 38,
|
||||
},
|
||||
last_token_usage: {
|
||||
input_tokens: 30,
|
||||
cached_input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
reasoning_output_tokens: 1,
|
||||
total_tokens: 38,
|
||||
},
|
||||
model_context_window: 200000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
fs.writeFileSync(rolloutPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-native-agg-'));
|
||||
ccsDir = path.join(tempRoot, '.ccs');
|
||||
claudeDir = path.join(tempRoot, '.claude');
|
||||
codexDir = path.join(tempRoot, '.codex-native');
|
||||
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CCS_DIR = ccsDir;
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir;
|
||||
process.env.CODEX_HOME = codexDir;
|
||||
|
||||
writeUnifiedConfigFixture();
|
||||
writeClaudeJsonlFixture();
|
||||
writeCliproxySnapshotFixture();
|
||||
writeCodexFixture();
|
||||
|
||||
aggregator = await import('../../../src/web-server/usage/aggregator');
|
||||
aggregator.clearUsageCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
aggregator.shutdownUsageAggregator();
|
||||
aggregator.clearUsageCache();
|
||||
|
||||
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
|
||||
else delete process.env.CCS_DIR;
|
||||
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
else delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
if (originalCodexHome !== undefined) process.env.CODEX_HOME = originalCodexHome;
|
||||
else delete process.env.CODEX_HOME;
|
||||
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('usage aggregator native runtime integration', () => {
|
||||
it('merges native codex usage with claude and cliproxy analytics', async () => {
|
||||
const daily = await aggregator.getCachedDailyData();
|
||||
|
||||
expect(daily).toHaveLength(1);
|
||||
expect(daily[0]).toMatchObject({
|
||||
date: '2026-03-02',
|
||||
inputTokens: 180,
|
||||
outputTokens: 53,
|
||||
cacheReadTokens: 10,
|
||||
});
|
||||
expect(daily[0].modelsUsed).toContain('claude-sonnet-4-5');
|
||||
expect(daily[0].modelsUsed).toContain('gemini-2.5-pro');
|
||||
expect(daily[0].modelsUsed).toContain('gpt-5');
|
||||
});
|
||||
|
||||
it('ignores cliproxy-backed codex rollouts to avoid double counting', async () => {
|
||||
writeCodexFixture('cliproxy');
|
||||
aggregator.clearUsageCache();
|
||||
|
||||
const daily = await aggregator.getCachedDailyData();
|
||||
|
||||
expect(daily).toHaveLength(1);
|
||||
expect(daily[0]).toMatchObject({
|
||||
inputTokens: 150,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 5,
|
||||
});
|
||||
expect(daily[0].modelsUsed).not.toContain('gpt-5');
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,12 @@ interface MockResponse {
|
||||
|
||||
let tempHome = '';
|
||||
let claudeDir = '';
|
||||
let codexDir = '';
|
||||
let handlers: HandlersModule;
|
||||
let aggregator: AggregatorModule;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let originalCodexHome: string | undefined;
|
||||
|
||||
function writeUnifiedConfigFixture(): void {
|
||||
const yaml = `version: 2
|
||||
@@ -103,11 +105,14 @@ function createMockResponse(): MockResponse {
|
||||
beforeEach(async () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-handlers-'));
|
||||
claudeDir = path.join(tempHome, '.claude');
|
||||
codexDir = path.join(tempHome, '.codex');
|
||||
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir;
|
||||
process.env.CODEX_HOME = codexDir;
|
||||
|
||||
writeUnifiedConfigFixture();
|
||||
|
||||
@@ -133,6 +138,12 @@ afterEach(() => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
|
||||
if (originalCodexHome !== undefined) {
|
||||
process.env.CODEX_HOME = originalCodexHome;
|
||||
} else {
|
||||
delete process.env.CODEX_HOME;
|
||||
}
|
||||
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user