Skip to content
Merged
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
66 changes: 65 additions & 1 deletion cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ const {
extractSessionDetailPreviewFromTailText,
extractSessionDetailPreviewFromFileFast
} = require('./lib/cli-sessions');
const { listSessionUsageCore } = require('./cli/session-usage');
const { listSessionUsageCore, exportSessionUsageCore } = require('./cli/session-usage');
const { parseAnalyticsExportArgs } = require('./cli/analytics-export-args');
const {
readBundledWebUiCss,
readBundledWebUiHtml,
Expand Down Expand Up @@ -5204,6 +5205,12 @@ async function listSessionUsage(params = {}) {
});
}

async function exportSessionUsage(params = {}) {
return exportSessionUsageCore(params, {
listSessionUsage
});
}

function listSessionPaths(params = {}) {
const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') {
Expand Down Expand Up @@ -9796,6 +9803,47 @@ async function cmdExportSession(args = []) {
console.log();
}

function printAnalyticsUsage() {
console.log('\n用法:');
console.log(' codexmate analytics export [--format csv|json] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--model <MODEL>] [--source <codex|claude|gemini|codebuddy|all>] [--output <PATH|->] [-o <PATH|->]');
console.log('');
}

async function cmdAnalytics(args = []) {
const subcommand = args[0];
if (subcommand !== 'export') {
printAnalyticsUsage();
process.exit(subcommand ? 1 : 0);
}
const parsed = parseAnalyticsExportArgs(args.slice(1));
if (parsed.options.help) {
printAnalyticsUsage();
process.exit(0);
}
if (parsed.error) {
console.error('错误:', parsed.error);
printAnalyticsUsage();
process.exit(1);
}

const result = await exportSessionUsage(parsed.options);
if (result && result.error) {
console.error('导出失败:', result.error);
process.exit(1);
}
const output = parsed.options.output || (result && result.fileName) || `usage-export.${parsed.options.format}`;
if (output === '-') {
process.stdout.write(result && result.content ? result.content : '');
return;
}
const outputPath = path.resolve(process.cwd(), output);
ensureDir(path.dirname(outputPath));
fs.writeFileSync(outputPath, result && result.content ? result.content : '', 'utf-8');
console.log(`\n✓ Usage 已导出: ${outputPath}`);
console.log(` 格式: ${result.format}; rows: ${Array.isArray(result.rows) ? result.rows.length : 0}`);
console.log();
}

function parseStartOptions(args = []) {
const options = { host: '', noBrowser: false };
if (!Array.isArray(args)) {
Expand Down Expand Up @@ -11077,6 +11125,20 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
}
}
break;
case 'export-sessions-usage':
{
const usageParams = isPlainObject(params) ? params : {};
const source = typeof usageParams.source === 'string' ? usageParams.source.trim().toLowerCase() : '';
if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') {
result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' };
} else {
result = await exportSessionUsage({
...usageParams,
source: source || 'all'
});
}
}
break;
case 'list-session-paths':
{
const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
Expand Down Expand Up @@ -15960,6 +16022,7 @@ function printMainHelp() {
console.log(' codexmate delete-model <模型> 删除模型');
console.log(' codexmate workflow <list|get|validate|run|runs> MCP 工作流中心');
console.log(' codexmate task <plan|run|runs|queue|retry|cancel|logs> 本地任务编排');
console.log(' codexmate analytics export [--format csv|json] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--model <MODEL>] [--output <PATH|->] [-o <PATH|->] 导出 Usage 数据');
console.log(' codexmate run [--host <HOST>] [--no-browser] 启动 Web 界面');
console.log(' codexmate update [--check] 检查并快速更新工具');
console.log(' codexmate codex [参数...] [--follow-up <文本>|--queued-follow-up <文本> 可重复] 等同于 codex --yolo');
Expand Down Expand Up @@ -16052,6 +16115,7 @@ async function main() {
case 'proxy': await cmdProxy(args.slice(1)); break;
case 'workflow': await cmdWorkflow(args.slice(1)); break;
case 'task': await cmdTask(args.slice(1)); break;
case 'analytics': await cmdAnalytics(args.slice(1)); break;
case 'run': cmdStart(parseStartOptions(args.slice(1))); break;
case 'update': await cmdToolUpdate(args.slice(1)); break;
case 'start':
Expand Down
68 changes: 68 additions & 0 deletions cli/analytics-export-args.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
function parseAnalyticsExportArgs(args = []) {
const options = {
format: 'csv',
source: 'all',
output: ''
};
const errors = [];
for (let index = 0; index < args.length; index += 1) {
const token = String(args[index] || '');
const readValue = (flag) => {
if (token.startsWith(`${flag}=`)) {
return token.slice(flag.length + 1);
}
const value = args[index + 1];
index += 1;
return value;
};
if (token === '--format' || token.startsWith('--format=')) {
options.format = String(readValue('--format') || '').trim().toLowerCase();
continue;
}
if (token === '--from' || token.startsWith('--from=')) {
options.from = String(readValue('--from') || '').trim();
continue;
}
if (token === '--to' || token.startsWith('--to=')) {
options.to = String(readValue('--to') || '').trim();
continue;
}
if (token === '--model' || token.startsWith('--model=')) {
options.model = String(readValue('--model') || '').trim();
continue;
}
if (token === '--source' || token.startsWith('--source=')) {
options.source = String(readValue('--source') || '').trim().toLowerCase();
continue;
}
if (token === '--output' || token === '-o' || token.startsWith('--output=')) {
options.output = String(readValue(token === '-o' ? '-o' : '--output') || '').trim();
continue;
}
if (token === '--force-refresh') {
options.forceRefresh = true;
continue;
}
if (token === '--help' || token === '-h') {
options.help = true;
continue;
}
if (token) {
errors.push(`未知参数 ${token}`);
}
}
if (options.format !== 'csv' && options.format !== 'json') {
errors.push('--format 必须是 csv 或 json');
}
if (options.source && !['codex', 'claude', 'gemini', 'codebuddy', 'all'].includes(options.source)) {
errors.push('--source 必须是 codex、claude、gemini、codebuddy 或 all');
}
return {
options,
error: errors.join(';')
};
}

module.exports = {
parseAnalyticsExportArgs
};
188 changes: 187 additions & 1 deletion cli/session-usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,192 @@ async function listSessionUsageCore(params = {}, deps = {}) {
return normalizedSessions.filter(Boolean);
}

function readNonNegativeInteger(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return 0;
}
return Math.floor(numeric);
}

function parseUsageExportDate(value, boundary) {
if (value === undefined || value === null || value === '') {
return null;
}
if (value instanceof Date) {
const time = value.getTime();
return Number.isFinite(time) ? time : NaN;
}
const raw = String(value).trim();
if (!raw) {
return null;
}
const dateOnly = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (dateOnly) {
const year = Number(dateOnly[1]);
const month = Number(dateOnly[2]) - 1;
const day = Number(dateOnly[3]);
const start = Date.UTC(year, month, day);
const normalized = new Date(start);
if (!Number.isFinite(start)
|| normalized.getUTCFullYear() !== year
|| normalized.getUTCMonth() !== month
|| normalized.getUTCDate() !== day) {
return NaN;
}
return boundary === 'end' ? start + 24 * 60 * 60 * 1000 : start;
}
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : NaN;
}

function formatUsageExportDay(timestamp) {
return new Date(timestamp).toISOString().slice(0, 10);
}

function normalizeUsageExportFormat(value) {
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
return normalized === 'json' ? 'json' : 'csv';
}

function normalizeUsageExportModelFilters(params = {}) {
const raw = [];
const push = (value) => {
if (Array.isArray(value)) {
value.forEach(push);
return;
}
if (typeof value !== 'string') {
return;
}
value.split(',').forEach((item) => {
const normalized = item.trim().toLowerCase();
if (normalized) raw.push(normalized);
});
};
push(params.model);
push(params.models);
// API-facing alias: callers may pass modelType when they reuse usage filters
// outside the CLI flag surface.
push(params.modelType);
return [...new Set(raw)];
}

function sessionMatchesUsageExportModelFilters(session, filters) {
if (!filters.length) {
return true;
}
const models = [];
if (typeof session.model === 'string') models.push(session.model);
if (Array.isArray(session.models)) models.push(...session.models.filter(item => typeof item === 'string'));
const normalizedModels = models.map(item => item.trim().toLowerCase()).filter(Boolean);
return filters.some(filter => normalizedModels.some(model => model === filter || model.includes(filter)));
}

function escapeUsageCsvCell(value) {
const raw = value === undefined || value === null ? '' : String(value);
if (!/[",\n\r]/.test(raw)) {
return raw;
}
return `"${raw.replace(/"/g, '""')}"`;
}

function serializeUsageExportRowsToCsv(rows) {
const columns = ['date', 'model', 'tokens', 'sessions'];
const lines = [columns.join(',')];
for (const row of rows) {
lines.push(columns.map(column => escapeUsageCsvCell(row[column])).join(','));
}
return lines.join('\r\n') + '\r\n';
}

function buildUsageExportRows(sessions = [], params = {}) {
const fromTime = parseUsageExportDate(params.from ?? params.startDate, 'start');
const toTime = parseUsageExportDate(params.to ?? params.endDate, 'end');
if (Number.isNaN(fromTime)) {
return { error: 'Invalid from date' };
}
if (Number.isNaN(toTime)) {
return { error: 'Invalid to date' };
}
if (fromTime !== null && toTime !== null && fromTime >= toTime) {
return { error: 'from date must be before to date' };
}

const modelFilters = normalizeUsageExportModelFilters(params);
const groups = new Map();
for (const session of Array.isArray(sessions) ? sessions : []) {
if (!session || typeof session !== 'object' || Array.isArray(session)) {
continue;
}
if (!sessionMatchesUsageExportModelFilters(session, modelFilters)) {
continue;
}
const timestamp = Date.parse(session.updatedAt || session.createdAt || '');
if (!Number.isFinite(timestamp)) {
continue;
}
if (fromTime !== null && timestamp < fromTime) {
continue;
}
if (toTime !== null && timestamp >= toTime) {
continue;
}
const model = typeof session.model === 'string' && session.model.trim()
? session.model.trim()
: (Array.isArray(session.models) && typeof session.models[0] === 'string' ? session.models[0].trim() : 'unknown');
if (!model) {
continue;
}
const date = formatUsageExportDay(timestamp);
const key = `${date}\u0000${model}`;
const current = groups.get(key) || { date, model, tokens: 0, sessions: 0 };
current.tokens += readNonNegativeInteger(session.totalTokens ?? session.tokens);
current.sessions += 1;
groups.set(key, current);
}

const rows = [...groups.values()].sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
if (dateCompare !== 0) return dateCompare;
return a.model.localeCompare(b.model);
});
return { rows };
}

async function exportSessionUsageCore(params = {}, deps = {}) {
const listSessionUsage = typeof deps.listSessionUsage === 'function'
? deps.listSessionUsage
: (options) => listSessionUsageCore(options, deps);
const sessions = Array.isArray(params.sessions)
? params.sessions
: await listSessionUsage({
source: params.source,
limit: params.limit,
forceRefresh: !!params.forceRefresh
});
const built = buildUsageExportRows(sessions, params);
if (built.error) {
return { error: built.error };
}
const format = normalizeUsageExportFormat(params.format);
const rows = built.rows;
const content = format === 'json'
? JSON.stringify({ rows }, null, 2) + '\n'
: serializeUsageExportRowsToCsv(rows);
const extension = format === 'json' ? 'json' : 'csv';
return {
format,
mimeType: format === 'json' ? 'application/json' : 'text/csv',
fileName: `usage-export.${extension}`,
rows,
content
};
}

module.exports = {
listSessionUsageCore
listSessionUsageCore,
buildUsageExportRows,
exportSessionUsageCore,
serializeUsageExportRowsToCsv
};
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codexmate",
"version": "0.0.37",
"version": "0.0.38",
"description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
"main": "cli.js",
"bin": {
Expand Down
Loading
Loading