diff --git a/cli.js b/cli.js index e69eaaee..3ce92f93 100644 --- a/cli.js +++ b/cli.js @@ -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, @@ -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') { @@ -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 ] [--source ] [--output ] [-o ]'); + 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)) { @@ -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() : ''; @@ -15960,6 +16022,7 @@ function printMainHelp() { console.log(' codexmate delete-model <模型> 删除模型'); console.log(' codexmate workflow MCP 工作流中心'); console.log(' codexmate task 本地任务编排'); + console.log(' codexmate analytics export [--format csv|json] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--model ] [--output ] [-o ] 导出 Usage 数据'); console.log(' codexmate run [--host ] [--no-browser] 启动 Web 界面'); console.log(' codexmate update [--check] 检查并快速更新工具'); console.log(' codexmate codex [参数...] [--follow-up <文本>|--queued-follow-up <文本> 可重复] 等同于 codex --yolo'); @@ -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': diff --git a/cli/analytics-export-args.js b/cli/analytics-export-args.js new file mode 100644 index 00000000..f1057b59 --- /dev/null +++ b/cli/analytics-export-args.js @@ -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 +}; diff --git a/cli/session-usage.js b/cli/session-usage.js index 7cd2055b..ab53091c 100644 --- a/cli/session-usage.js +++ b/cli/session-usage.js @@ -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 }; diff --git a/package-lock.json b/package-lock.json index 3aebd6ba..dbd804a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codexmate", - "version": "0.0.37", + "version": "0.0.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codexmate", - "version": "0.0.37", + "version": "0.0.38", "license": "Apache-2.0", "dependencies": { "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index 1a55c2a4..cf1e5447 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/tests/unit/analytics-export-args.test.mjs b/tests/unit/analytics-export-args.test.mjs new file mode 100644 index 00000000..70e65b43 --- /dev/null +++ b/tests/unit/analytics-export-args.test.mjs @@ -0,0 +1,56 @@ +import assert from 'assert'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); +const { parseAnalyticsExportArgs } = require(path.join(__dirname, '..', '..', 'cli', 'analytics-export-args.js')); + +test('parseAnalyticsExportArgs accepts equals-style values and output shorthand', () => { + const parsed = parseAnalyticsExportArgs([ + '--format=json', + '--from=2026-05-01', + '--to', '2026-05-06', + '--model', 'gpt-5.3', + '--source=codex', + '-o', '-', + '--force-refresh' + ]); + + assert.strictEqual(parsed.error, ''); + assert.deepStrictEqual(parsed.options, { + format: 'json', + source: 'codex', + output: '-', + from: '2026-05-01', + to: '2026-05-06', + model: 'gpt-5.3', + forceRefresh: true + }); +}); + +test('parseAnalyticsExportArgs accepts long output assignment and help flag', () => { + const parsed = parseAnalyticsExportArgs(['--output=usage.csv', '--help']); + + assert.strictEqual(parsed.error, ''); + assert.deepStrictEqual(parsed.options, { + format: 'csv', + source: 'all', + output: 'usage.csv', + help: true + }); +}); + +test('parseAnalyticsExportArgs reports unknown tokens and invalid choices', () => { + const parsed = parseAnalyticsExportArgs([ + '--format', 'xml', + '--source', 'openai', + '--surprise' + ]); + + assert.match(parsed.error, /未知参数 --surprise/); + assert.match(parsed.error, /--format 必须是 csv 或 json/); + assert.match(parsed.error, /--source 必须是 codex、claude、gemini、codebuddy 或 all/); +}); diff --git a/tests/unit/run.mjs b/tests/unit/run.mjs index 31a7a19e..0e24cbbc 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -27,6 +27,7 @@ await import(pathToFileURL(path.join(__dirname, 'web-ui-source-bundle.test.mjs') await import(pathToFileURL(path.join(__dirname, 'startup-claude-star-prompt.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'install-methods.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'cli-help.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'analytics-export-args.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'cli-network-utils.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'config-health-module.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'openclaw-core.test.mjs'))); diff --git a/tests/unit/session-usage-backend.test.mjs b/tests/unit/session-usage-backend.test.mjs index f76d36d0..fb62fad7 100644 --- a/tests/unit/session-usage-backend.test.mjs +++ b/tests/unit/session-usage-backend.test.mjs @@ -251,6 +251,54 @@ test('listSessionUsage normalizes source and default limit for lightweight usage ]); }); +test('exportSessionUsageCore exports filtered usage rows as csv and json', async () => { + const sessions = [ + { model: 'gpt-5.3-codex', models: ['gpt-5.3-codex'], updatedAt: '2026-05-01T10:00:00.000Z', totalTokens: 120 }, + { model: 'gpt-5.3-codex', models: ['gpt-5.3-codex'], updatedAt: '2026-05-01T12:00:00.000Z', totalTokens: 30 }, + { model: 'claude-sonnet', models: ['claude-sonnet'], updatedAt: '2026-05-02T09:00:00.000Z', totalTokens: 400 }, + { model: 'gpt-5.2-codex', models: ['gpt-5.2-codex'], updatedAt: '2026-05-07T09:00:00.000Z', totalTokens: 999 } + ]; + + const csv = await usageCore.exportSessionUsageCore({ + sessions, + format: 'csv', + from: '2026-05-01', + to: '2026-05-06', + model: 'gpt-5.3' + }); + + assert.strictEqual(csv.format, 'csv'); + assert.strictEqual(csv.mimeType, 'text/csv'); + assert.deepStrictEqual(csv.rows, [ + { date: '2026-05-01', model: 'gpt-5.3-codex', tokens: 150, sessions: 2 } + ]); + assert.strictEqual(csv.content, 'date,model,tokens,sessions\r\n2026-05-01,gpt-5.3-codex,150,2\r\n'); + + const json = await usageCore.exportSessionUsageCore({ + sessions, + format: 'json', + from: '2026-05-01', + to: '2026-05-06' + }); + + assert.strictEqual(json.format, 'json'); + assert.deepStrictEqual(JSON.parse(json.content), { + rows: [ + { date: '2026-05-01', model: 'gpt-5.3-codex', tokens: 150, sessions: 2 }, + { date: '2026-05-02', model: 'claude-sonnet', tokens: 400, sessions: 1 } + ] + }); +}); + +test('exportSessionUsageCore handles empty data gracefully', async () => { + const csv = await usageCore.exportSessionUsageCore({ sessions: [], format: 'csv' }); + assert.strictEqual(csv.content, 'date,model,tokens,sessions\r\n'); + assert.deepStrictEqual(csv.rows, []); + + const json = await usageCore.exportSessionUsageCore({ sessions: [], format: 'json' }); + assert.deepStrictEqual(JSON.parse(json.content), { rows: [] }); +}); + test('listSessionUsage backfills missing model metadata from parsed session summaries', async () => { const codexParses = []; const claudeParses = [];