token-studio 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nvmrc +1 -0
- package/CHANGELOG.md +89 -0
- package/Dockerfile +17 -0
- package/LICENSE +22 -0
- package/NOTICE.md +21 -0
- package/PRIVACY.md +68 -0
- package/README.en.md +220 -0
- package/README.md +220 -0
- package/config/collectors.json +54 -0
- package/data/.gitkeep +1 -0
- package/docker-compose.yml +17 -0
- package/docs/assets/.gitkeep +1 -0
- package/docs/assets/token-studio-v44-dashboard.png +0 -0
- package/docs/assets/token-studio-v44-live.png +0 -0
- package/docs/assets/token-studio-v44-review-mobile.png +0 -0
- package/docs/assets/token-studio-v44-review.png +0 -0
- package/docs/assets/token-studio-v45-dashboard.png +0 -0
- package/docs/assets/token-studio-v45-live.png +0 -0
- package/docs/assets/token-studio-v45-review-mobile.png +0 -0
- package/docs/assets/token-studio-v45-review.png +0 -0
- package/docs/blog-case-study.md +34 -0
- package/docs/collector-support-matrix.md +65 -0
- package/docs/competitive-notes.md +87 -0
- package/docs/demo-data/README.md +12 -0
- package/docs/demo-data/token-studio-v2-demo.json +146 -0
- package/docs/demo-flow.md +39 -0
- package/docs/first-run.md +95 -0
- package/docs/local-collectors.md +49 -0
- package/docs/public-launch-checklist.md +45 -0
- package/docs/resume-bullets.md +7 -0
- package/docs/statusline.md +52 -0
- package/index.html +16 -0
- package/package.json +36 -0
- package/render.yaml +17 -0
- package/src/auto-attribution.mjs +396 -0
- package/src/ccusage-bridge.mjs +74 -0
- package/src/ccusage-import.mjs +415 -0
- package/src/cli.mjs +643 -0
- package/src/client/dashboard/App.jsx +1734 -0
- package/src/client/dashboard/annotation-presets.js +138 -0
- package/src/client/dashboard/attribution.js +328 -0
- package/src/client/dashboard/components-charts.jsx +622 -0
- package/src/client/dashboard/components-tables.jsx +1531 -0
- package/src/client/dashboard/components-top.jsx +307 -0
- package/src/client/dashboard/import-budget.js +41 -0
- package/src/client/dashboard/model-usage.js +108 -0
- package/src/client/dashboard/onboarding.js +80 -0
- package/src/client/dashboard/styles.css +2606 -0
- package/src/client/live/LiveApp.jsx +226 -0
- package/src/client/live/styles.css +446 -0
- package/src/client/main.jsx +20 -0
- package/src/client/review/ReviewApp.jsx +507 -0
- package/src/client/review/closure-progress.js +165 -0
- package/src/client/review/markdown-report.js +401 -0
- package/src/client/review/model-strategy.js +273 -0
- package/src/client/review/roi-advisor.js +255 -0
- package/src/client/review/roi-evidence.js +78 -0
- package/src/client/review/savings-simulator.js +252 -0
- package/src/client/review/sections-1.jsx +277 -0
- package/src/client/review/sections-2.jsx +927 -0
- package/src/client/review/styles.css +2321 -0
- package/src/client/review/utils.js +345 -0
- package/src/client/shared/utils.js +236 -0
- package/src/closure-check.mjs +537 -0
- package/src/closure-import.mjs +646 -0
- package/src/collect.mjs +247 -0
- package/src/collector-config.mjs +82 -0
- package/src/collector-registry.mjs +333 -0
- package/src/collectors/claude-code.mjs +355 -0
- package/src/collectors/codex.mjs +418 -0
- package/src/collectors/copilot.mjs +19 -0
- package/src/collectors/cursor.mjs +23 -0
- package/src/collectors/gemini.mjs +530 -0
- package/src/collectors/goose.mjs +15 -0
- package/src/collectors/hermes.mjs +206 -0
- package/src/collectors/kimi.mjs +15 -0
- package/src/collectors/openclaw.mjs +400 -0
- package/src/collectors/opencode.mjs +349 -0
- package/src/collectors/qwen.mjs +15 -0
- package/src/collectors/structured-usage.mjs +437 -0
- package/src/collectors/utils.mjs +93 -0
- package/src/db.mjs +1397 -0
- package/src/demo-seed.mjs +39 -0
- package/src/dev.mjs +43 -0
- package/src/live.mjs +428 -0
- package/src/model-policy.mjs +147 -0
- package/src/pricing.mjs +434 -0
- package/src/privacy-check.mjs +126 -0
- package/src/server.mjs +1240 -0
- package/src/source-health.mjs +195 -0
- package/src/statusline.mjs +156 -0
- package/src/terminal-report.mjs +245 -0
- package/src/update-pricing.mjs +8 -0
- package/test/annotation-presets.test.mjs +137 -0
- package/test/api-annotations.test.mjs +202 -0
- package/test/api-auto-attribution.test.mjs +169 -0
- package/test/api-source-health.test.mjs +109 -0
- package/test/api-v2.test.mjs +278 -0
- package/test/api-v43.test.mjs +151 -0
- package/test/api-v44.test.mjs +128 -0
- package/test/attribution-summary.test.mjs +164 -0
- package/test/auto-attribution.test.mjs +116 -0
- package/test/ccusage-bridge.test.mjs +36 -0
- package/test/ccusage-import.test.mjs +93 -0
- package/test/cli-v43.test.mjs +64 -0
- package/test/cli-v45.test.mjs +34 -0
- package/test/cli-v46.test.mjs +129 -0
- package/test/cli-v47.test.mjs +98 -0
- package/test/closure-check.test.mjs +202 -0
- package/test/closure-import.test.mjs +263 -0
- package/test/collector-config.test.mjs +25 -0
- package/test/collector-registry.test.mjs +56 -0
- package/test/csv.test.mjs +19 -0
- package/test/db-annotations.test.mjs +186 -0
- package/test/db-v2.test.mjs +200 -0
- package/test/db-v4.test.mjs +178 -0
- package/test/experimental-collectors.test.mjs +103 -0
- package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
- package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
- package/test/fixtures/collectors/goose/usage.jsonl +2 -0
- package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
- package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
- package/test/import-budget.test.mjs +40 -0
- package/test/live.test.mjs +256 -0
- package/test/markdown-report.test.mjs +193 -0
- package/test/model-policy.test.mjs +34 -0
- package/test/model-strategy.test.mjs +116 -0
- package/test/model-usage.test.mjs +99 -0
- package/test/official-pricing.test.mjs +70 -0
- package/test/onboarding.test.mjs +55 -0
- package/test/privacy-check.test.mjs +33 -0
- package/test/review-closure-progress.test.mjs +99 -0
- package/test/roi-advisor.test.mjs +188 -0
- package/test/roi-evidence.test.mjs +48 -0
- package/test/roi-summary.test.mjs +101 -0
- package/test/savings-simulator.test.mjs +141 -0
- package/test/source-health.test.mjs +62 -0
- package/test/statusline.test.mjs +148 -0
- package/vite.config.js +23 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
buildReviewAttributionProgress,
|
|
5
|
+
buildReviewAttributionChecklist,
|
|
6
|
+
buildReviewUnattributedSessions,
|
|
7
|
+
buildAttributionStatusSummary,
|
|
8
|
+
buildUnattributedSessions,
|
|
9
|
+
isReviewUnattributedSession,
|
|
10
|
+
isUnattributedSession
|
|
11
|
+
} from '../src/client/dashboard/attribution.js';
|
|
12
|
+
|
|
13
|
+
const sessions = [
|
|
14
|
+
{
|
|
15
|
+
sessionId: 'published',
|
|
16
|
+
taskType: '功能开发',
|
|
17
|
+
outputStatus: '已发布',
|
|
18
|
+
workPurpose: '功能开发',
|
|
19
|
+
workStage: '发布',
|
|
20
|
+
valueLevel: '高',
|
|
21
|
+
inputTokens: 60,
|
|
22
|
+
outputTokens: 40,
|
|
23
|
+
totalTokens: 100,
|
|
24
|
+
costUSD: 1
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
sessionId: 'completed-without-task',
|
|
28
|
+
taskType: '未分类',
|
|
29
|
+
outputStatus: '已完成',
|
|
30
|
+
workPurpose: '功能开发',
|
|
31
|
+
workStage: '实现',
|
|
32
|
+
valueLevel: '中',
|
|
33
|
+
inputTokens: 30,
|
|
34
|
+
outputTokens: 20,
|
|
35
|
+
totalTokens: 50,
|
|
36
|
+
costUSD: 0.5
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
sessionId: 'unmarked-status',
|
|
40
|
+
taskType: '问题修复',
|
|
41
|
+
outputStatus: '未标注',
|
|
42
|
+
workPurpose: '调试修复',
|
|
43
|
+
workStage: '验证',
|
|
44
|
+
valueLevel: '中',
|
|
45
|
+
inputTokens: 15,
|
|
46
|
+
outputTokens: 10,
|
|
47
|
+
totalTokens: 25,
|
|
48
|
+
costUSD: 0.25
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
sessionId: 'discarded',
|
|
52
|
+
taskType: '技术调研',
|
|
53
|
+
outputStatus: '已废弃',
|
|
54
|
+
workPurpose: '未说明',
|
|
55
|
+
workStage: '探索',
|
|
56
|
+
valueLevel: '低',
|
|
57
|
+
inputTokens: 20,
|
|
58
|
+
outputTokens: 5,
|
|
59
|
+
totalTokens: 25,
|
|
60
|
+
costUSD: 0.1
|
|
61
|
+
}
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
test('isUnattributedSession uses the broad task-or-status rule', () => {
|
|
65
|
+
assert.equal(isUnattributedSession(sessions[0]), false);
|
|
66
|
+
assert.equal(isUnattributedSession(sessions[1]), true);
|
|
67
|
+
assert.equal(isUnattributedSession(sessions[2]), true);
|
|
68
|
+
assert.equal(isUnattributedSession({}), true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('isReviewUnattributedSession also requires purpose stage and value', () => {
|
|
72
|
+
assert.equal(isReviewUnattributedSession(sessions[0]), false);
|
|
73
|
+
assert.equal(isReviewUnattributedSession(sessions[1]), true);
|
|
74
|
+
assert.equal(isReviewUnattributedSession(sessions[2]), true);
|
|
75
|
+
assert.equal(isReviewUnattributedSession(sessions[3]), true);
|
|
76
|
+
assert.equal(isReviewUnattributedSession({
|
|
77
|
+
taskType: '功能开发',
|
|
78
|
+
outputStatus: '已完成',
|
|
79
|
+
workPurpose: '功能开发',
|
|
80
|
+
workStage: '未说明',
|
|
81
|
+
valueLevel: '高'
|
|
82
|
+
}), true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('buildReviewUnattributedSessions sorts v3 review gaps by token cost', () => {
|
|
86
|
+
const rows = buildReviewUnattributedSessions(sessions);
|
|
87
|
+
assert.deepEqual(rows.map(row => row.sessionId), ['completed-without-task', 'unmarked-status', 'discarded']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('buildReviewAttributionProgress reports session and token completion', () => {
|
|
91
|
+
const progress = buildReviewAttributionProgress(sessions);
|
|
92
|
+
|
|
93
|
+
assert.equal(progress.sessionCount, 4);
|
|
94
|
+
assert.equal(progress.attributedSessionCount, 1);
|
|
95
|
+
assert.equal(progress.unattributedSessionCount, 3);
|
|
96
|
+
assert.equal(progress.totalTokens, 200);
|
|
97
|
+
assert.equal(progress.attributedTokens, 100);
|
|
98
|
+
assert.equal(progress.unattributedTokens, 100);
|
|
99
|
+
assert.equal(progress.completionShare, 0.25);
|
|
100
|
+
assert.equal(progress.tokenCompletionShare, 0.5);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('buildReviewAttributionChecklist copies highest cost real work gaps', () => {
|
|
104
|
+
const checklist = buildReviewAttributionChecklist([
|
|
105
|
+
...sessions,
|
|
106
|
+
{
|
|
107
|
+
sessionId: '=danger',
|
|
108
|
+
projectAlias: '+formula|project',
|
|
109
|
+
taskType: '功能开发',
|
|
110
|
+
outputStatus: '已完成',
|
|
111
|
+
workPurpose: '未说明',
|
|
112
|
+
workStage: '未说明',
|
|
113
|
+
valueLevel: '未评估',
|
|
114
|
+
totalTokens: 75,
|
|
115
|
+
costUSD: 2,
|
|
116
|
+
model: 'gpt-5.5',
|
|
117
|
+
source: 'Codex CLI',
|
|
118
|
+
lastActivity: '2026-06-12'
|
|
119
|
+
}
|
|
120
|
+
], {
|
|
121
|
+
limit: 2,
|
|
122
|
+
generatedAt: new Date(2026, 5, 12, 9, 30)
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
assert.match(checklist, /^# Token Studio 归因工作清单/);
|
|
126
|
+
assert.match(checklist, /不包含对话正文/);
|
|
127
|
+
assert.match(checklist, /人工核对项目、任务、目的、阶段、价值和产出状态/);
|
|
128
|
+
assert.match(checklist, /任务类型/);
|
|
129
|
+
assert.match(checklist, /工作目的、工作阶段、产出价值/);
|
|
130
|
+
assert.match(checklist, /'\=danger/);
|
|
131
|
+
assert.match(checklist, /'\+formula\\\|project/);
|
|
132
|
+
assert.equal(checklist.includes('unmarked-status'), false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('buildUnattributedSessions filters and sorts highest token work first', () => {
|
|
136
|
+
const rows = buildUnattributedSessions(sessions);
|
|
137
|
+
assert.deepEqual(rows.map(row => row.sessionId), ['completed-without-task', 'unmarked-status']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('buildAttributionStatusSummary aggregates output status and unattributed rows', () => {
|
|
141
|
+
const summary = buildAttributionStatusSummary(sessions);
|
|
142
|
+
const byId = Object.fromEntries(summary.map(row => [row.id, row]));
|
|
143
|
+
|
|
144
|
+
assert.equal(byId.published.sessionCount, 1);
|
|
145
|
+
assert.equal(byId.published.totalTokens, 100);
|
|
146
|
+
assert.equal(byId.published.costUSD, 1);
|
|
147
|
+
assert.equal(byId.published.share, 0.5);
|
|
148
|
+
|
|
149
|
+
assert.equal(byId.completed.sessionCount, 1);
|
|
150
|
+
assert.equal(byId.completed.totalTokens, 50);
|
|
151
|
+
assert.equal(byId.completed.share, 0.25);
|
|
152
|
+
|
|
153
|
+
assert.equal(byId.inProgress.sessionCount, 0);
|
|
154
|
+
assert.equal(byId.inProgress.totalTokens, 0);
|
|
155
|
+
|
|
156
|
+
assert.equal(byId.discarded.sessionCount, 1);
|
|
157
|
+
assert.equal(byId.discarded.totalTokens, 25);
|
|
158
|
+
assert.equal(byId.discarded.share, 0.125);
|
|
159
|
+
|
|
160
|
+
assert.equal(byId.unattributed.sessionCount, 2);
|
|
161
|
+
assert.equal(byId.unattributed.totalTokens, 75);
|
|
162
|
+
assert.equal(byId.unattributed.costUSD, 0.75);
|
|
163
|
+
assert.equal(byId.unattributed.share, 0.375);
|
|
164
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
attachAutoSuggestions,
|
|
5
|
+
buildAutoAttributionPlan,
|
|
6
|
+
buildAutoAttributionSuggestion
|
|
7
|
+
} from '../src/auto-attribution.mjs';
|
|
8
|
+
|
|
9
|
+
const baseSession = {
|
|
10
|
+
device: 'local',
|
|
11
|
+
source: 'Codex CLI',
|
|
12
|
+
sessionId: 'local:codex:D:\\HighROIProjects\\TokenStudio:gpt-5.5',
|
|
13
|
+
projectPath: 'D:\\HighROIProjects\\TokenStudio',
|
|
14
|
+
taskType: '未分类',
|
|
15
|
+
outputStatus: '未标注',
|
|
16
|
+
workPurpose: '未说明',
|
|
17
|
+
workStage: '未说明',
|
|
18
|
+
valueLevel: '未评估',
|
|
19
|
+
inputTokens: 100_000,
|
|
20
|
+
outputTokens: 20_000,
|
|
21
|
+
totalTokens: 120_000,
|
|
22
|
+
costUSD: 3,
|
|
23
|
+
lastActivity: '2026-06-15'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
test('project alias rules create high-confidence suggestions', () => {
|
|
27
|
+
const suggestion = buildAutoAttributionSuggestion(baseSession, {
|
|
28
|
+
projectAliasRules: [{
|
|
29
|
+
enabled: true,
|
|
30
|
+
matchType: 'prefix',
|
|
31
|
+
pattern: 'D:\\HighROIProjects\\TokenStudio',
|
|
32
|
+
projectAlias: 'Token Studio'
|
|
33
|
+
}],
|
|
34
|
+
now: new Date('2026-06-30T00:00:00Z')
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
assert.equal(suggestion.values.projectAlias, 'Token Studio');
|
|
38
|
+
assert.equal(suggestion.annotationConfidence, 92);
|
|
39
|
+
assert.equal(suggestion.canApply, true);
|
|
40
|
+
assert.match(suggestion.annotationReason, /项目别名规则/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('output links infer productive work without writing critical value', () => {
|
|
44
|
+
const suggestion = buildAutoAttributionSuggestion({
|
|
45
|
+
...baseSession,
|
|
46
|
+
outputUrl: 'https://github.com/example/repo/pull/42',
|
|
47
|
+
outputType: 'PR'
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
assert.equal(suggestion.values.taskType, '功能开发');
|
|
51
|
+
assert.equal(suggestion.values.outputStatus, '已完成');
|
|
52
|
+
assert.equal(suggestion.values.workPurpose, '功能开发');
|
|
53
|
+
assert.equal(suggestion.values.workStage, '实现');
|
|
54
|
+
assert.equal(suggestion.values.valueLevel, '中');
|
|
55
|
+
assert.notEqual(suggestion.values.valueLevel, '关键');
|
|
56
|
+
assert.equal(suggestion.annotationConfidence, 80);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('deploy output infers published high value but not critical value', () => {
|
|
60
|
+
const suggestion = buildAutoAttributionSuggestion({
|
|
61
|
+
...baseSession,
|
|
62
|
+
outputUrl: 'https://example.com/app',
|
|
63
|
+
outputType: '部署'
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
assert.equal(suggestion.values.outputStatus, '已发布');
|
|
67
|
+
assert.equal(suggestion.values.workStage, '发布');
|
|
68
|
+
assert.equal(suggestion.values.valueLevel, '高');
|
|
69
|
+
assert.notEqual(suggestion.values.valueLevel, '关键');
|
|
70
|
+
assert.equal(suggestion.annotationConfidence, 80);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('high input low output only creates low-confidence review suggestions', () => {
|
|
74
|
+
const suggestion = buildAutoAttributionSuggestion({
|
|
75
|
+
...baseSession,
|
|
76
|
+
projectPath: '',
|
|
77
|
+
inputTokens: 900_000,
|
|
78
|
+
outputTokens: 50_000,
|
|
79
|
+
totalTokens: 1_000_000
|
|
80
|
+
}, {
|
|
81
|
+
now: new Date('2026-06-30T00:00:00Z')
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
assert.equal(suggestion.values.taskType, '技术调研');
|
|
85
|
+
assert.equal(suggestion.values.workPurpose, '上下文整理');
|
|
86
|
+
assert.equal(suggestion.values.outputStatus, '未标注');
|
|
87
|
+
assert.equal(suggestion.annotationConfidence, 65);
|
|
88
|
+
assert.equal(suggestion.canApply, false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('manual or imported annotations are never suggested for overwrite', () => {
|
|
92
|
+
assert.equal(buildAutoAttributionSuggestion({
|
|
93
|
+
...baseSession,
|
|
94
|
+
annotationSource: 'manual',
|
|
95
|
+
annotationConfidence: 100
|
|
96
|
+
}), null);
|
|
97
|
+
assert.equal(buildAutoAttributionSuggestion({
|
|
98
|
+
...baseSession,
|
|
99
|
+
annotationSource: 'imported',
|
|
100
|
+
annotationConfidence: 100
|
|
101
|
+
}), null);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('plan reports lazy-mode reduction and attaches suggestions', () => {
|
|
105
|
+
const sessions = [
|
|
106
|
+
{ ...baseSession, sessionId: 's1', outputUrl: 'https://example.com/doc', outputType: '文档' },
|
|
107
|
+
{ ...baseSession, sessionId: 's2', annotationSource: 'manual', taskType: '功能开发' }
|
|
108
|
+
];
|
|
109
|
+
const plan = buildAutoAttributionPlan({ sessions, now: new Date('2026-06-16T00:00:00Z') });
|
|
110
|
+
const attached = attachAutoSuggestions(sessions, plan.suggestions);
|
|
111
|
+
|
|
112
|
+
assert.equal(plan.highConfidenceCount, 1);
|
|
113
|
+
assert.equal(plan.lowConfidenceCount, 0);
|
|
114
|
+
assert.equal(attached[0].autoSuggestion.canApply, true);
|
|
115
|
+
assert.equal(attached[1].autoSuggestion, null);
|
|
116
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { ccusageInvocation } from '../src/ccusage-bridge.mjs';
|
|
4
|
+
import { planCcusageImport } from '../src/ccusage-import.mjs';
|
|
5
|
+
|
|
6
|
+
test('ccusage bridge builds npx invocation with json and no-cost flags', () => {
|
|
7
|
+
const invocation = ccusageInvocation({ report: 'daily' });
|
|
8
|
+
assert.match(invocation.command, process.platform === 'win32' ? /npx\.cmd$/ : /npx$/);
|
|
9
|
+
assert.deepEqual(invocation.args, ['ccusage@latest', 'daily', '--json', '--no-cost']);
|
|
10
|
+
assert.equal(invocation.commandLabel, 'npx ccusage@latest daily --json --no-cost');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('ccusage bridge accepts explicit binary and rejects unknown reports', () => {
|
|
14
|
+
const invocation = ccusageInvocation({ report: 'blocks', ccusageBin: 'ccusage' });
|
|
15
|
+
assert.equal(invocation.command, 'ccusage');
|
|
16
|
+
assert.deepEqual(invocation.args, ['blocks', '--json', '--no-cost']);
|
|
17
|
+
assert.equal(invocation.commandLabel, 'ccusage blocks --json --no-cost');
|
|
18
|
+
assert.throws(() => ccusageInvocation({ report: 'bad' }), /--report must be one of/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('ccusage import planner supports weekly report shape', () => {
|
|
22
|
+
const plan = planCcusageImport({
|
|
23
|
+
type: 'weekly',
|
|
24
|
+
data: [{
|
|
25
|
+
week: '2026-06-15',
|
|
26
|
+
source: 'Codex CLI',
|
|
27
|
+
models: ['gpt-5.3-codex'],
|
|
28
|
+
inputTokens: 100,
|
|
29
|
+
outputTokens: 25,
|
|
30
|
+
totalTokens: 125
|
|
31
|
+
}]
|
|
32
|
+
}, { device: 'test-device' });
|
|
33
|
+
assert.equal(plan.detectedShape, 'weekly');
|
|
34
|
+
assert.equal(plan.sessions.length, 1);
|
|
35
|
+
assert.equal(plan.tokenEvents.length, 1);
|
|
36
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { applyCcusageImport, parseCcusageJsonText, planCcusageImport } from '../src/ccusage-import.mjs';
|
|
7
|
+
import { openDb } from '../src/db.mjs';
|
|
8
|
+
|
|
9
|
+
function tempDb() {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'token-studio-ccusage-'));
|
|
11
|
+
return openDb(join(dir, 'usage.sqlite'));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('ccusage import supports documented daily and project daily shapes', () => {
|
|
15
|
+
const daily = planCcusageImport({
|
|
16
|
+
daily: [{
|
|
17
|
+
date: '2026-06-17',
|
|
18
|
+
modelsUsed: ['<synthetic>'],
|
|
19
|
+
inputTokens: 1000,
|
|
20
|
+
outputTokens: 200,
|
|
21
|
+
cacheReadTokens: 300,
|
|
22
|
+
totalTokens: 1500,
|
|
23
|
+
totalCost: 99
|
|
24
|
+
}]
|
|
25
|
+
}, { device: 'test-device', now: new Date('2026-06-17T10:00:00Z') });
|
|
26
|
+
|
|
27
|
+
assert.equal(daily.detectedShape, 'daily');
|
|
28
|
+
assert.equal(daily.daily.length, 1);
|
|
29
|
+
assert.equal(daily.sessions.length, 1);
|
|
30
|
+
assert.equal(daily.daily[0].costUSD < 99, true);
|
|
31
|
+
assert.equal(daily.warnings[0].type, 'ignored-imported-cost');
|
|
32
|
+
|
|
33
|
+
const projectDaily = planCcusageImport({
|
|
34
|
+
projects: {
|
|
35
|
+
'token-studio-roi': [{
|
|
36
|
+
date: '2026-06-17',
|
|
37
|
+
modelsUsed: ['claude-sonnet-4'],
|
|
38
|
+
inputTokens: 500,
|
|
39
|
+
outputTokens: 100
|
|
40
|
+
}]
|
|
41
|
+
}
|
|
42
|
+
}, { device: 'test-device' });
|
|
43
|
+
|
|
44
|
+
assert.equal(projectDaily.detectedShape, 'project-daily');
|
|
45
|
+
assert.equal(projectDaily.sessions[0].projectPath, 'token-studio-roi');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('ccusage import supports session, blocks and monthly reports', () => {
|
|
49
|
+
for (const payload of [
|
|
50
|
+
{
|
|
51
|
+
type: 'session',
|
|
52
|
+
data: [{ session: 's1', models: ['gpt-5.3-codex'], inputTokens: 100, outputTokens: 20, firstActivity: '2026-06-17T01:00:00Z', lastActivity: '2026-06-17T02:00:00Z' }]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
type: 'blocks',
|
|
56
|
+
data: [{ blockStart: '2026-06-17T01:00:00Z', blockEnd: '2026-06-17T02:00:00Z', models: ['gpt-5.3-codex'], inputTokens: 100, outputTokens: 20 }]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: 'monthly',
|
|
60
|
+
data: [{ month: '2026-06', models: ['gpt-5.3-codex'], inputTokens: 100, outputTokens: 20 }]
|
|
61
|
+
}
|
|
62
|
+
]) {
|
|
63
|
+
const plan = planCcusageImport(payload, { device: 'test-device' });
|
|
64
|
+
assert.equal(plan.daily.length, 1);
|
|
65
|
+
assert.equal(plan.sessions.length, 1);
|
|
66
|
+
assert.equal(plan.tokenEvents.length, 1);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('ccusage apply is idempotent and dry-run plans do not write', () => {
|
|
71
|
+
const db = tempDb();
|
|
72
|
+
const payload = {
|
|
73
|
+
type: 'session',
|
|
74
|
+
data: [{ session: 's1', models: ['gpt-5.3-codex'], inputTokens: 100, outputTokens: 20, lastActivity: '2026-06-17T02:00:00Z' }]
|
|
75
|
+
};
|
|
76
|
+
const plan = planCcusageImport(payload, { device: 'test-device' });
|
|
77
|
+
|
|
78
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM session_usage').get().count, 0);
|
|
79
|
+
applyCcusageImport(db, plan);
|
|
80
|
+
applyCcusageImport(db, plan);
|
|
81
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM daily_usage').get().count, 1);
|
|
82
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM session_usage').get().count, 1);
|
|
83
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM token_events').get().count, 1);
|
|
84
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM collection_runs WHERE source = ?').get('import:ccusage-json').count, 2);
|
|
85
|
+
db.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('ccusage parser rejects conversation-like fields', () => {
|
|
89
|
+
assert.throws(() => parseCcusageJsonText(JSON.stringify({
|
|
90
|
+
type: 'session',
|
|
91
|
+
data: [{ session: 's1', prompt: 'do not ingest', inputTokens: 1 }]
|
|
92
|
+
})), /conversation-like field/);
|
|
93
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
test('v4.3 CLI imports ccusage dry-run/apply and prints report JSON', async () => {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), 'token-studio-cli-v43-'));
|
|
10
|
+
const dbPath = join(dir, 'usage.sqlite');
|
|
11
|
+
const jsonPath = join(dir, 'ccusage.json');
|
|
12
|
+
writeFileSync(jsonPath, JSON.stringify({
|
|
13
|
+
type: 'session',
|
|
14
|
+
data: [{
|
|
15
|
+
session: 's1',
|
|
16
|
+
models: ['gpt-5.3-codex'],
|
|
17
|
+
inputTokens: 100,
|
|
18
|
+
outputTokens: 20,
|
|
19
|
+
lastActivity: '2026-06-17T02:00:00Z'
|
|
20
|
+
}]
|
|
21
|
+
}), 'utf8');
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const dryRun = await runCli(['import-usage', '--format=ccusage-json', '--file', jsonPath, '--db', dbPath, '--dry-run', '--json']);
|
|
25
|
+
assert.equal(dryRun.code, 0, dryRun.stderr);
|
|
26
|
+
assert.equal(JSON.parse(dryRun.stdout).mode, 'dry-run');
|
|
27
|
+
|
|
28
|
+
const applied = await runCli(['import-usage', '--format=ccusage-json', '--file', jsonPath, '--db', dbPath, '--apply', '--json']);
|
|
29
|
+
assert.equal(applied.code, 0, applied.stderr);
|
|
30
|
+
assert.equal(JSON.parse(applied.stdout).applied.sessions, 1);
|
|
31
|
+
|
|
32
|
+
const budget = await runCli(['budget', 'set', '--db', dbPath, '--source', 'Codex CLI', '--label', 'Codex 15m', '--window-minutes', '15', '--token-budget', '1000']);
|
|
33
|
+
assert.equal(budget.code, 0, budget.stderr);
|
|
34
|
+
|
|
35
|
+
const list = await runCli(['budget', 'list', '--db', dbPath, '--json']);
|
|
36
|
+
assert.equal(list.code, 0, list.stderr);
|
|
37
|
+
assert.equal(JSON.parse(list.stdout).profiles.length, 1);
|
|
38
|
+
|
|
39
|
+
const report = await runCli(['report', '--db', dbPath, '--period', 'all', '--format', 'json']);
|
|
40
|
+
assert.equal(report.code, 0, report.stderr);
|
|
41
|
+
assert.equal(JSON.parse(report.stdout).totals.totalTokens, 120);
|
|
42
|
+
} finally {
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function runCli(argv, env = {}) {
|
|
48
|
+
return new Promise(resolve => {
|
|
49
|
+
const child = spawn(process.execPath, ['src/cli.mjs', ...argv], {
|
|
50
|
+
cwd: process.cwd(),
|
|
51
|
+
env: { ...process.env, ...env },
|
|
52
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
53
|
+
windowsHide: true
|
|
54
|
+
});
|
|
55
|
+
let stdout = '';
|
|
56
|
+
let stderr = '';
|
|
57
|
+
child.stdout.setEncoding('utf8');
|
|
58
|
+
child.stderr.setEncoding('utf8');
|
|
59
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
60
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
61
|
+
child.on('close', code => resolve({ code, stdout, stderr }));
|
|
62
|
+
child.on('error', error => resolve({ code: 1, stdout, stderr: `${stderr}${error.message}` }));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
test('CLI help exposes open and import-usage help', async () => {
|
|
6
|
+
const help = await runCli(['--help']);
|
|
7
|
+
assert.equal(help.code, 0, help.stderr);
|
|
8
|
+
assert.match(help.stdout, /token-studio open/);
|
|
9
|
+
|
|
10
|
+
const importHelp = await runCli(['import-usage', '--help']);
|
|
11
|
+
assert.equal(importHelp.code, 0, importHelp.stderr);
|
|
12
|
+
assert.match(importHelp.stdout, /ccusage Import/);
|
|
13
|
+
assert.match(importHelp.stdout, /--dry-run/);
|
|
14
|
+
assert.match(importHelp.stdout, /prompt, response, messages/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function runCli(argv) {
|
|
18
|
+
return new Promise(resolve => {
|
|
19
|
+
const child = spawn(process.execPath, ['src/cli.mjs', ...argv], {
|
|
20
|
+
cwd: process.cwd(),
|
|
21
|
+
env: process.env,
|
|
22
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
23
|
+
windowsHide: true
|
|
24
|
+
});
|
|
25
|
+
let stdout = '';
|
|
26
|
+
let stderr = '';
|
|
27
|
+
child.stdout.setEncoding('utf8');
|
|
28
|
+
child.stderr.setEncoding('utf8');
|
|
29
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
30
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
31
|
+
child.on('close', code => resolve({ code, stdout, stderr }));
|
|
32
|
+
child.on('error', error => resolve({ code: 1, stdout, stderr: `${stderr}${error.message}` }));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { openDb } from '../src/db.mjs';
|
|
8
|
+
|
|
9
|
+
test('v4.6 CLI bridge refuses non-interactive external scans without --yes', async () => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'token-studio-cli-v46-refuse-'));
|
|
11
|
+
const dbPath = join(dir, 'usage.sqlite');
|
|
12
|
+
const mock = createMockCcusage(dir);
|
|
13
|
+
try {
|
|
14
|
+
const result = await runCli(['import-usage', '--format=ccusage-cli', '--report=session', '--ccusage-bin', mock, '--db', dbPath, '--dry-run', '--json']);
|
|
15
|
+
assert.notEqual(result.code, 0);
|
|
16
|
+
assert.match(result.stderr, /requires --yes/);
|
|
17
|
+
assert.equal(existsSync(dbPath), false);
|
|
18
|
+
} finally {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('v4.6 CLI bridge dry-run/apply imports ccusage CLI JSON safely', async () => {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), 'token-studio-cli-v46-bridge-'));
|
|
25
|
+
const dbPath = join(dir, 'usage.sqlite');
|
|
26
|
+
const mock = createMockCcusage(dir);
|
|
27
|
+
try {
|
|
28
|
+
const dryRun = await runCli(['import-usage', '--format=ccusage-cli', '--report=session', '--ccusage-bin', mock, '--db', dbPath, '--dry-run', '--yes', '--json']);
|
|
29
|
+
assert.equal(dryRun.code, 0, dryRun.stderr);
|
|
30
|
+
const dryRunBody = JSON.parse(dryRun.stdout);
|
|
31
|
+
assert.equal(dryRunBody.mode, 'dry-run');
|
|
32
|
+
assert.equal(dryRunBody.format, 'ccusage-cli');
|
|
33
|
+
assert.equal(dryRunBody.bridge.report, 'session');
|
|
34
|
+
assert.equal(dryRunBody.sessions, 1);
|
|
35
|
+
assert.equal(existsSync(dbPath), false);
|
|
36
|
+
|
|
37
|
+
const applied = await runCli(['import-usage', '--format=ccusage-cli', '--report=session', '--ccusage-bin', mock, '--db', dbPath, '--apply', '--yes', '--json']);
|
|
38
|
+
assert.equal(applied.code, 0, applied.stderr);
|
|
39
|
+
const appliedBody = JSON.parse(applied.stdout);
|
|
40
|
+
assert.equal(appliedBody.applied.sessions, 1);
|
|
41
|
+
assert.equal(appliedBody.applied.tokenEvents, 1);
|
|
42
|
+
assert.ok(appliedBody.backup?.path);
|
|
43
|
+
assert.ok(existsSync(appliedBody.backup.path));
|
|
44
|
+
|
|
45
|
+
const db = openDb(dbPath);
|
|
46
|
+
try {
|
|
47
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM collection_runs WHERE source = ?').get('import:ccusage-cli').count, 1);
|
|
48
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM token_events WHERE tool_category = ?').get('import:ccusage-cli').count, 1);
|
|
49
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM session_usage').get().count, 1);
|
|
50
|
+
} finally {
|
|
51
|
+
db.close();
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('v4.6 CLI bridge rejects unsafe ccusage CLI JSON', async () => {
|
|
59
|
+
const dir = mkdtempSync(join(tmpdir(), 'token-studio-cli-v46-unsafe-'));
|
|
60
|
+
const dbPath = join(dir, 'usage.sqlite');
|
|
61
|
+
const mock = createMockCcusage(dir);
|
|
62
|
+
try {
|
|
63
|
+
const result = await runCli(['import-usage', '--format=ccusage-cli', '--report=session', '--ccusage-bin', mock, '--db', dbPath, '--dry-run', '--yes', '--json'], {
|
|
64
|
+
TOKEN_STUDIO_MOCK_CCUSAGE_OUTPUT: 'unsafe'
|
|
65
|
+
});
|
|
66
|
+
assert.notEqual(result.code, 0);
|
|
67
|
+
assert.match(result.stderr, /conversation-like field/);
|
|
68
|
+
assert.equal(existsSync(dbPath), false);
|
|
69
|
+
} finally {
|
|
70
|
+
rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('v4.6 CLI bridge reports external command failures clearly', async () => {
|
|
75
|
+
const dir = mkdtempSync(join(tmpdir(), 'token-studio-cli-v46-fail-'));
|
|
76
|
+
const dbPath = join(dir, 'usage.sqlite');
|
|
77
|
+
const mock = createMockCcusage(dir);
|
|
78
|
+
try {
|
|
79
|
+
const result = await runCli(['import-usage', '--format=ccusage-cli', '--report=session', '--ccusage-bin', mock, '--db', dbPath, '--dry-run', '--yes', '--json'], {
|
|
80
|
+
TOKEN_STUDIO_MOCK_CCUSAGE_OUTPUT: 'fail'
|
|
81
|
+
});
|
|
82
|
+
assert.notEqual(result.code, 0);
|
|
83
|
+
assert.match(result.stderr, /ccusage CLI failed/);
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function createMockCcusage(dir) {
|
|
90
|
+
const scriptPath = join(dir, 'mock-ccusage.mjs');
|
|
91
|
+
writeFileSync(scriptPath, [
|
|
92
|
+
"const mode = process.env.TOKEN_STUDIO_MOCK_CCUSAGE_OUTPUT || 'safe';",
|
|
93
|
+
"if (!process.argv.includes('--json') || !process.argv.includes('--no-cost')) { console.error('missing expected flags'); process.exit(3); }",
|
|
94
|
+
"if (mode === 'fail') { console.error('mock failure'); process.exit(7); }",
|
|
95
|
+
"const row = { session: 'cli-bridge-s1', source: 'Codex CLI', models: ['gpt-5.3-codex'], inputTokens: 100, outputTokens: 20, cacheReadTokens: 5, lastActivity: '2026-06-17T02:00:00Z' };",
|
|
96
|
+
"if (mode === 'unsafe') row.prompt = 'secret prompt';",
|
|
97
|
+
"console.log(JSON.stringify({ type: 'session', data: [row] }));"
|
|
98
|
+
].join('\n'), 'utf8');
|
|
99
|
+
|
|
100
|
+
if (process.platform === 'win32') {
|
|
101
|
+
const cmdPath = join(dir, 'mock-ccusage.cmd');
|
|
102
|
+
writeFileSync(cmdPath, `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`, 'utf8');
|
|
103
|
+
return cmdPath;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const binPath = join(dir, 'mock-ccusage');
|
|
107
|
+
writeFileSync(binPath, `#!/usr/bin/env sh\n"${process.execPath}" "${scriptPath}" "$@"\n`, 'utf8');
|
|
108
|
+
chmodSync(binPath, 0o755);
|
|
109
|
+
return binPath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function runCli(argv, env = {}) {
|
|
113
|
+
return new Promise(resolve => {
|
|
114
|
+
const child = spawn(process.execPath, ['src/cli.mjs', ...argv], {
|
|
115
|
+
cwd: process.cwd(),
|
|
116
|
+
env: { ...process.env, ...env },
|
|
117
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
118
|
+
windowsHide: true
|
|
119
|
+
});
|
|
120
|
+
let stdout = '';
|
|
121
|
+
let stderr = '';
|
|
122
|
+
child.stdout.setEncoding('utf8');
|
|
123
|
+
child.stderr.setEncoding('utf8');
|
|
124
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
125
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
126
|
+
child.on('close', code => resolve({ code, stdout, stderr }));
|
|
127
|
+
child.on('error', error => resolve({ code: 1, stdout, stderr: `${stderr}${error.message}` }));
|
|
128
|
+
});
|
|
129
|
+
}
|