tonik 0.1.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/LICENSE +21 -0
- package/README.md +68 -0
- package/dist/cli.js +420 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Takahiro Inaba
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# tonik
|
|
2
|
+
|
|
3
|
+
**Score and tune how you use AI — right in your terminal.**
|
|
4
|
+
|
|
5
|
+
`tonik` reads your local Claude Code session logs and shows a beautiful dashboard
|
|
6
|
+
of how efficiently you're using AI: token spend, cost, cache efficiency, and a
|
|
7
|
+
single score that tells you whether things are getting better or worse. Then it
|
|
8
|
+
points at the concrete waste and tells you how to fix it.
|
|
9
|
+
|
|
10
|
+
No account. No API key. No data leaves your machine — it just reads the logs
|
|
11
|
+
Claude Code already keeps locally.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
$ npx tonik
|
|
15
|
+
|
|
16
|
+
╭─ Usage · tinaba96 · 14d ──────────────╮ ╭─ Score ────╮
|
|
17
|
+
│ ▁▂▃▅▇▆▄▃▂▄▆█ ↓ 18% │ │ │
|
|
18
|
+
│ in 4.2M out 1.1M cost $38.20 │ │ ⬢ 72/100 │
|
|
19
|
+
╰───────────────────────────────────────╯ │ ▲ +6 │
|
|
20
|
+
╭─ Health ──────────────────────────────╮ ╰────────────╯
|
|
21
|
+
│ sessions 63 turns/session 7.4 ⚠ │
|
|
22
|
+
│ cache hit 41% ⚠ │
|
|
23
|
+
╰────────────────────────────────────────╯
|
|
24
|
+
╭─ Findings ────────────────────────────────────────────╮
|
|
25
|
+
│ ✂ low cache reuse — $9.10 spent re-creating context │
|
|
26
|
+
│ ✂ 7.4 turns/session — try splitting prompts │
|
|
27
|
+
╰────────────────────────────────────────────────────────╯
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npx tonik # run without installing
|
|
34
|
+
npm i -g tonik # or install globally
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
tonik # dashboard for the current project (last 14 days)
|
|
41
|
+
tonik --global # all projects combined
|
|
42
|
+
tonik --since 7d # change the time window
|
|
43
|
+
tonik --theme card # switch visual theme (dashboard | minimal | card)
|
|
44
|
+
tonik --json # machine-readable output (for CI / scripts)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How the score works
|
|
48
|
+
|
|
49
|
+
The score is intentionally transparent — it's the sum of three explainable
|
|
50
|
+
sub-scores, each grounded in numbers you can verify:
|
|
51
|
+
|
|
52
|
+
| Sub-score | Max | Based on |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| Efficiency | 40 | Cache hit rate (reused context ÷ created context) |
|
|
55
|
+
| Conversation | 30 | Avg human turns per session (fewer, well-scoped is better) |
|
|
56
|
+
| Cost discipline | 30 | Token spend trend over the window (improving earns more) |
|
|
57
|
+
|
|
58
|
+
No magic, no black box. If you don't like the weighting, it's open source.
|
|
59
|
+
|
|
60
|
+
## Roadmap
|
|
61
|
+
|
|
62
|
+
- `tonik fix` — apply the suggested improvements, not just list them
|
|
63
|
+
- More themes and custom color palettes
|
|
64
|
+
- A `tonik-core` library so other tools can reuse the analysis
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
[MIT](./LICENSE) © Takahiro Inaba
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.tsx
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
|
|
6
|
+
// src/logs.ts
|
|
7
|
+
import { createReadStream } from "fs";
|
|
8
|
+
import { readdir, stat } from "fs/promises";
|
|
9
|
+
import { createInterface } from "readline";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
var PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
13
|
+
function cwdToProjectDir(cwd) {
|
|
14
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
15
|
+
}
|
|
16
|
+
function projectLabel(projectDir) {
|
|
17
|
+
const parts = projectDir.split("-").filter(Boolean);
|
|
18
|
+
return parts[parts.length - 1] ?? projectDir;
|
|
19
|
+
}
|
|
20
|
+
function num(v) {
|
|
21
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
22
|
+
}
|
|
23
|
+
function isHumanPrompt(record) {
|
|
24
|
+
if (record?.type !== "user") return false;
|
|
25
|
+
if (record.toolUseResult || record.isMeta) return false;
|
|
26
|
+
const content = record?.message?.content;
|
|
27
|
+
if (typeof content === "string") return content.length > 0;
|
|
28
|
+
if (Array.isArray(content)) {
|
|
29
|
+
return content.some((b) => b?.type === "text" || typeof b === "string");
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
function parseLine(line, project, since) {
|
|
34
|
+
let record;
|
|
35
|
+
try {
|
|
36
|
+
record = JSON.parse(line);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const ts = record?.timestamp ? Date.parse(record.timestamp) : NaN;
|
|
41
|
+
if (!Number.isFinite(ts) || ts < since) return null;
|
|
42
|
+
const usage = record?.message?.usage;
|
|
43
|
+
return {
|
|
44
|
+
ts,
|
|
45
|
+
type: record?.type ?? "unknown",
|
|
46
|
+
sessionId: record?.sessionId ?? "unknown",
|
|
47
|
+
project,
|
|
48
|
+
model: record?.message?.model ?? null,
|
|
49
|
+
input: num(usage?.input_tokens),
|
|
50
|
+
output: num(usage?.output_tokens),
|
|
51
|
+
cacheRead: num(usage?.cache_read_input_tokens),
|
|
52
|
+
cacheCreation: num(usage?.cache_creation_input_tokens),
|
|
53
|
+
isHumanPrompt: isHumanPrompt(record)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function readJsonl(file, project, since) {
|
|
57
|
+
const entries = [];
|
|
58
|
+
const rl = createInterface({
|
|
59
|
+
input: createReadStream(file, { encoding: "utf8" }),
|
|
60
|
+
crlfDelay: Infinity
|
|
61
|
+
});
|
|
62
|
+
for await (const line of rl) {
|
|
63
|
+
if (!line.trim()) continue;
|
|
64
|
+
const entry = parseLine(line, project, since);
|
|
65
|
+
if (entry) entries.push(entry);
|
|
66
|
+
}
|
|
67
|
+
return entries;
|
|
68
|
+
}
|
|
69
|
+
async function loadEntries(opts) {
|
|
70
|
+
let projectDirs;
|
|
71
|
+
try {
|
|
72
|
+
projectDirs = await readdir(PROJECTS_DIR);
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
if (opts.projectDir) {
|
|
77
|
+
projectDirs = projectDirs.filter((d) => d === opts.projectDir);
|
|
78
|
+
}
|
|
79
|
+
const all = [];
|
|
80
|
+
for (const dir of projectDirs) {
|
|
81
|
+
const dirPath = join(PROJECTS_DIR, dir);
|
|
82
|
+
let files;
|
|
83
|
+
try {
|
|
84
|
+
const s = await stat(dirPath);
|
|
85
|
+
if (!s.isDirectory()) continue;
|
|
86
|
+
files = (await readdir(dirPath)).filter((f) => f.endsWith(".jsonl"));
|
|
87
|
+
} catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
for (const f of files) {
|
|
91
|
+
try {
|
|
92
|
+
const entries = await readJsonl(join(dirPath, f), dir, opts.since);
|
|
93
|
+
all.push(...entries);
|
|
94
|
+
} catch {
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return all;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/pricing.ts
|
|
102
|
+
var PRICES = {
|
|
103
|
+
opus: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
104
|
+
sonnet: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
105
|
+
haiku: { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 }
|
|
106
|
+
};
|
|
107
|
+
var DEFAULT_PRICE = PRICES.sonnet;
|
|
108
|
+
function priceFor(model) {
|
|
109
|
+
if (!model) return DEFAULT_PRICE;
|
|
110
|
+
const m = model.toLowerCase();
|
|
111
|
+
if (m.includes("opus")) return PRICES.opus;
|
|
112
|
+
if (m.includes("sonnet")) return PRICES.sonnet;
|
|
113
|
+
if (m.includes("haiku")) return PRICES.haiku;
|
|
114
|
+
return DEFAULT_PRICE;
|
|
115
|
+
}
|
|
116
|
+
function entryCost(e) {
|
|
117
|
+
const p = priceFor(e.model);
|
|
118
|
+
return (e.input * p.input + e.output * p.output + e.cacheCreation * p.cacheWrite + e.cacheRead * p.cacheRead) / 1e6;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/metrics.ts
|
|
122
|
+
var DAY_MS = 864e5;
|
|
123
|
+
function clamp01(n) {
|
|
124
|
+
return Math.max(0, Math.min(1, n));
|
|
125
|
+
}
|
|
126
|
+
function computeMetrics(entries, opts) {
|
|
127
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
|
|
128
|
+
let cost = 0;
|
|
129
|
+
let wastedCost = 0;
|
|
130
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
131
|
+
let humanPrompts = 0;
|
|
132
|
+
const startDay = Math.floor(opts.since / DAY_MS);
|
|
133
|
+
const daily = new Array(opts.windowDays).fill(0);
|
|
134
|
+
for (const e of entries) {
|
|
135
|
+
totals.input += e.input;
|
|
136
|
+
totals.output += e.output;
|
|
137
|
+
totals.cacheRead += e.cacheRead;
|
|
138
|
+
totals.cacheCreation += e.cacheCreation;
|
|
139
|
+
cost += entryCost(e);
|
|
140
|
+
wastedCost += e.cacheCreation * priceFor(e.model).cacheWrite / 1e6;
|
|
141
|
+
if (e.sessionId !== "unknown") sessions.add(e.sessionId);
|
|
142
|
+
if (e.isHumanPrompt) humanPrompts += 1;
|
|
143
|
+
const idx = Math.floor(e.ts / DAY_MS) - startDay;
|
|
144
|
+
if (idx >= 0 && idx < opts.windowDays) {
|
|
145
|
+
daily[idx] += e.input + e.output + e.cacheRead + e.cacheCreation;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const cacheDenom = totals.cacheRead + totals.cacheCreation;
|
|
149
|
+
const cacheHitRate = cacheDenom > 0 ? totals.cacheRead / cacheDenom : 0;
|
|
150
|
+
const sessionCount = sessions.size;
|
|
151
|
+
const avgTurns = sessionCount > 0 ? humanPrompts / sessionCount : 0;
|
|
152
|
+
const half = Math.floor(opts.windowDays / 2);
|
|
153
|
+
const firstHalf = daily.slice(0, half).reduce((a, b) => a + b, 0);
|
|
154
|
+
const secondHalf = daily.slice(half).reduce((a, b) => a + b, 0);
|
|
155
|
+
const span = firstHalf + secondHalf;
|
|
156
|
+
const trendReliable = firstHalf > 0 && span > 0 && firstHalf / span >= 0.1;
|
|
157
|
+
const trendPct = trendReliable ? (secondHalf - firstHalf) / firstHalf * 100 : 0;
|
|
158
|
+
const hasData = entries.length > 0 && (cacheDenom > 0 || sessionCount > 0);
|
|
159
|
+
const efficiency = clamp01(cacheHitRate / 0.8) * 40;
|
|
160
|
+
const conversation = sessionCount > 0 ? clamp01((15 - avgTurns) / 10) * 30 : 0;
|
|
161
|
+
const costScore = !hasData ? 0 : trendReliable ? clamp01((20 - trendPct) / 30) * 30 : 20;
|
|
162
|
+
const subscores = {
|
|
163
|
+
efficiency: Math.round(efficiency),
|
|
164
|
+
conversation: Math.round(conversation),
|
|
165
|
+
cost: Math.round(costScore)
|
|
166
|
+
};
|
|
167
|
+
const score = subscores.efficiency + subscores.conversation + subscores.cost;
|
|
168
|
+
const findings = [];
|
|
169
|
+
if (cacheHitRate < 0.6 && wastedCost > 0.5) {
|
|
170
|
+
findings.push(
|
|
171
|
+
`low cache reuse (${Math.round(cacheHitRate * 100)}%) \u2014 $${wastedCost.toFixed(2)} spent re-creating context`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (avgTurns > 7) {
|
|
175
|
+
findings.push(
|
|
176
|
+
`${avgTurns.toFixed(1)} turns/session \u2014 try splitting work into clearer, scoped prompts`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (trendReliable && trendPct > 15) {
|
|
180
|
+
findings.push(
|
|
181
|
+
`token spend up ${Math.round(trendPct)}% this period \u2014 watch growing context size`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (hasData && findings.length === 0) {
|
|
185
|
+
findings.push("looking efficient \u2014 no major waste detected. Keep it up!");
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
scope: opts.scope,
|
|
189
|
+
windowDays: opts.windowDays,
|
|
190
|
+
totals,
|
|
191
|
+
cost,
|
|
192
|
+
wastedCost,
|
|
193
|
+
cacheHitRate,
|
|
194
|
+
sessions: sessionCount,
|
|
195
|
+
avgTurns,
|
|
196
|
+
trendPct,
|
|
197
|
+
daily,
|
|
198
|
+
score,
|
|
199
|
+
subscores,
|
|
200
|
+
findings,
|
|
201
|
+
hasData
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/ui.tsx
|
|
206
|
+
import { Box, Text } from "ink";
|
|
207
|
+
|
|
208
|
+
// src/format.ts
|
|
209
|
+
var BARS = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
|
|
210
|
+
function formatTokens(n) {
|
|
211
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
|
212
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
213
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
|
214
|
+
return String(Math.round(n));
|
|
215
|
+
}
|
|
216
|
+
function formatCost(n) {
|
|
217
|
+
return `$${n.toFixed(2)}`;
|
|
218
|
+
}
|
|
219
|
+
function formatPct(n) {
|
|
220
|
+
const rounded = Math.round(n);
|
|
221
|
+
if (rounded > 0) return `\u2191 ${rounded}%`;
|
|
222
|
+
if (rounded < 0) return `\u2193 ${Math.abs(rounded)}%`;
|
|
223
|
+
return `\u2192 0%`;
|
|
224
|
+
}
|
|
225
|
+
function sparkline(values, width = 24) {
|
|
226
|
+
if (values.length === 0) return "";
|
|
227
|
+
const series = values.length > width ? values.slice(values.length - width) : values;
|
|
228
|
+
const max = Math.max(...series);
|
|
229
|
+
if (max <= 0) return BARS[0].repeat(series.length);
|
|
230
|
+
return series.map((v) => {
|
|
231
|
+
const i = Math.round(v / max * (BARS.length - 1));
|
|
232
|
+
return BARS[Math.max(0, Math.min(BARS.length - 1, i))];
|
|
233
|
+
}).join("");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/ui.tsx
|
|
237
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
238
|
+
function scoreColor(score) {
|
|
239
|
+
if (score >= 70) return "green";
|
|
240
|
+
if (score >= 40) return "yellow";
|
|
241
|
+
return "red";
|
|
242
|
+
}
|
|
243
|
+
function scoreLabel(score) {
|
|
244
|
+
if (score >= 80) return "excellent";
|
|
245
|
+
if (score >= 60) return "good";
|
|
246
|
+
if (score >= 40) return "fair";
|
|
247
|
+
return "needs work";
|
|
248
|
+
}
|
|
249
|
+
function Panel(props) {
|
|
250
|
+
return /* @__PURE__ */ jsxs(
|
|
251
|
+
Box,
|
|
252
|
+
{
|
|
253
|
+
borderStyle: "round",
|
|
254
|
+
borderColor: "gray",
|
|
255
|
+
flexDirection: "column",
|
|
256
|
+
paddingX: 1,
|
|
257
|
+
flexGrow: props.flexGrow,
|
|
258
|
+
minWidth: props.minWidth,
|
|
259
|
+
width: props.width,
|
|
260
|
+
children: [
|
|
261
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: props.title }),
|
|
262
|
+
props.children
|
|
263
|
+
]
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
function warn(active) {
|
|
268
|
+
return active ? " \u26A0" : "";
|
|
269
|
+
}
|
|
270
|
+
function Dashboard({ m }) {
|
|
271
|
+
if (!m.hasData) {
|
|
272
|
+
return /* @__PURE__ */ jsx(Box, { paddingX: 1, paddingY: 1, children: /* @__PURE__ */ jsxs(Panel, { title: `tonik \xB7 ${m.scope}`, children: [
|
|
273
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
274
|
+
"No Claude Code usage found for this scope in the last ",
|
|
275
|
+
m.windowDays,
|
|
276
|
+
" days."
|
|
277
|
+
] }),
|
|
278
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "Try `tonik --global` or a wider window with `--since 30d`." })
|
|
279
|
+
] }) });
|
|
280
|
+
}
|
|
281
|
+
const inTokens = m.totals.input + m.totals.cacheRead + m.totals.cacheCreation;
|
|
282
|
+
const trendColor = m.trendPct <= 0 ? "green" : m.trendPct <= 15 ? "yellow" : "red";
|
|
283
|
+
const cacheLow = m.cacheHitRate < 0.6;
|
|
284
|
+
const turnsHigh = m.avgTurns > 7;
|
|
285
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [
|
|
286
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
287
|
+
/* @__PURE__ */ jsxs(Panel, { title: `Usage \xB7 ${m.scope} \xB7 ${m.windowDays}d`, flexGrow: 1, minWidth: 42, children: [
|
|
288
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
289
|
+
/* @__PURE__ */ jsx(Text, { color: "cyan", children: sparkline(m.daily) }),
|
|
290
|
+
/* @__PURE__ */ jsx(Box, { flexGrow: 1, justifyContent: "flex-end", children: /* @__PURE__ */ jsx(Text, { color: trendColor, children: formatPct(m.trendPct) }) })
|
|
291
|
+
] }),
|
|
292
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
293
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "in " }),
|
|
294
|
+
formatTokens(inTokens),
|
|
295
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: " out " }),
|
|
296
|
+
formatTokens(m.totals.output),
|
|
297
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: " cost " }),
|
|
298
|
+
/* @__PURE__ */ jsx(Text, { bold: true, children: formatCost(m.cost) })
|
|
299
|
+
] })
|
|
300
|
+
] }),
|
|
301
|
+
/* @__PURE__ */ jsx(Panel, { title: "Score", width: 16, children: /* @__PURE__ */ jsxs(Box, { flexDirection: "column", alignItems: "center", children: [
|
|
302
|
+
/* @__PURE__ */ jsxs(Text, { color: scoreColor(m.score), bold: true, children: [
|
|
303
|
+
"\u2B22 ",
|
|
304
|
+
m.score,
|
|
305
|
+
"/100"
|
|
306
|
+
] }),
|
|
307
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: scoreLabel(m.score) })
|
|
308
|
+
] }) })
|
|
309
|
+
] }),
|
|
310
|
+
/* @__PURE__ */ jsxs(Panel, { title: "Health", children: [
|
|
311
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
312
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "sessions " }),
|
|
313
|
+
m.sessions,
|
|
314
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: " turns/session " }),
|
|
315
|
+
/* @__PURE__ */ jsxs(Text, { color: turnsHigh ? "yellow" : void 0, children: [
|
|
316
|
+
m.avgTurns.toFixed(1),
|
|
317
|
+
warn(turnsHigh)
|
|
318
|
+
] }),
|
|
319
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: " cache hit " }),
|
|
320
|
+
/* @__PURE__ */ jsxs(Text, { color: cacheLow ? "yellow" : "green", children: [
|
|
321
|
+
Math.round(m.cacheHitRate * 100),
|
|
322
|
+
"%",
|
|
323
|
+
warn(cacheLow)
|
|
324
|
+
] })
|
|
325
|
+
] }),
|
|
326
|
+
/* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
327
|
+
"score = efficiency ",
|
|
328
|
+
m.subscores.efficiency,
|
|
329
|
+
"/40 \xB7 conversation",
|
|
330
|
+
" ",
|
|
331
|
+
m.subscores.conversation,
|
|
332
|
+
"/30 \xB7 cost ",
|
|
333
|
+
m.subscores.cost,
|
|
334
|
+
"/30"
|
|
335
|
+
] })
|
|
336
|
+
] }),
|
|
337
|
+
/* @__PURE__ */ jsx(Panel, { title: "Findings", children: m.findings.map((f, i) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
338
|
+
/* @__PURE__ */ jsx(Text, { color: f.startsWith("looking") ? "green" : "magenta", children: f.startsWith("looking") ? "\u2713 " : "\u2702 " }),
|
|
339
|
+
f
|
|
340
|
+
] }, i)) })
|
|
341
|
+
] });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/cli.tsx
|
|
345
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
346
|
+
var DAY_MS2 = 864e5;
|
|
347
|
+
function parseSince(v) {
|
|
348
|
+
if (!v) return 14;
|
|
349
|
+
const m = /^(\d+)\s*d?$/.exec(v.trim());
|
|
350
|
+
return m ? Math.max(1, parseInt(m[1], 10)) : 14;
|
|
351
|
+
}
|
|
352
|
+
function parseArgs(argv) {
|
|
353
|
+
const args = {
|
|
354
|
+
global: false,
|
|
355
|
+
sinceDays: 14,
|
|
356
|
+
theme: "dashboard",
|
|
357
|
+
json: false,
|
|
358
|
+
help: false,
|
|
359
|
+
command: null
|
|
360
|
+
};
|
|
361
|
+
for (let i = 0; i < argv.length; i++) {
|
|
362
|
+
const a = argv[i];
|
|
363
|
+
if (a === "--global" || a === "-g") args.global = true;
|
|
364
|
+
else if (a === "--json") args.json = true;
|
|
365
|
+
else if (a === "--help" || a === "-h") args.help = true;
|
|
366
|
+
else if (a === "--since") args.sinceDays = parseSince(argv[++i]);
|
|
367
|
+
else if (a.startsWith("--since=")) args.sinceDays = parseSince(a.slice("--since=".length));
|
|
368
|
+
else if (a === "--theme") args.theme = argv[++i] ?? "dashboard";
|
|
369
|
+
else if (a.startsWith("--theme=")) args.theme = a.slice("--theme=".length);
|
|
370
|
+
else if (!a.startsWith("-") && !args.command) args.command = a;
|
|
371
|
+
}
|
|
372
|
+
return args;
|
|
373
|
+
}
|
|
374
|
+
var HELP = `
|
|
375
|
+
tonik \u2014 score and tune how you use AI
|
|
376
|
+
|
|
377
|
+
Usage
|
|
378
|
+
$ tonik [command] [options]
|
|
379
|
+
|
|
380
|
+
Commands
|
|
381
|
+
(default) Show the usage dashboard for the current project
|
|
382
|
+
fix Apply suggested improvements (coming soon)
|
|
383
|
+
tips Show detailed improvement tips (coming soon)
|
|
384
|
+
|
|
385
|
+
Options
|
|
386
|
+
-g, --global Combine all projects instead of the current one
|
|
387
|
+
--since <Nd> Time window in days (default: 14d)
|
|
388
|
+
--theme <name> Visual theme: dashboard (default)
|
|
389
|
+
--json Output raw metrics as JSON
|
|
390
|
+
-h, --help Show this help
|
|
391
|
+
`;
|
|
392
|
+
async function main() {
|
|
393
|
+
const args = parseArgs(process.argv.slice(2));
|
|
394
|
+
if (args.help) {
|
|
395
|
+
console.log(HELP);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (args.command === "fix" || args.command === "tips") {
|
|
399
|
+
console.log(`
|
|
400
|
+
tonik ${args.command} is coming soon \u2014 for now, see the Findings panel.
|
|
401
|
+
`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const projectDir = args.global ? null : cwdToProjectDir(process.cwd());
|
|
405
|
+
const since = Date.now() - args.sinceDays * DAY_MS2;
|
|
406
|
+
const entries = await loadEntries({ projectDir, since });
|
|
407
|
+
const scope = args.global ? "all projects" : projectLabel(projectDir);
|
|
408
|
+
const metrics = computeMetrics(entries, { scope, windowDays: args.sinceDays, since });
|
|
409
|
+
if (args.json) {
|
|
410
|
+
console.log(JSON.stringify(metrics, null, 2));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const app = render(/* @__PURE__ */ jsx2(Dashboard, { m: metrics }));
|
|
414
|
+
app.unmount();
|
|
415
|
+
await app.waitUntilExit();
|
|
416
|
+
}
|
|
417
|
+
main().catch((err) => {
|
|
418
|
+
console.error(err);
|
|
419
|
+
process.exitCode = 1;
|
|
420
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tonik",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Score and tune how you use AI — right in your terminal.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tonik": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "tsx src/cli.tsx",
|
|
17
|
+
"build": "tsup src/cli.tsx --format esm --clean --target node18",
|
|
18
|
+
"start": "node dist/cli.js",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"cli",
|
|
23
|
+
"ai",
|
|
24
|
+
"claude",
|
|
25
|
+
"tokens",
|
|
26
|
+
"analytics",
|
|
27
|
+
"developer-tools",
|
|
28
|
+
"typescript",
|
|
29
|
+
"ink"
|
|
30
|
+
],
|
|
31
|
+
"author": "Takahiro Inaba (@tinaba96)",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/tinaba96/tonik.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/tinaba96/tonik#readme",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"ink": "^5.0.1",
|
|
40
|
+
"react": "^18.3.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^20.14.0",
|
|
44
|
+
"@types/react": "^18.3.3",
|
|
45
|
+
"tsup": "^8.1.0",
|
|
46
|
+
"tsx": "^4.16.0",
|
|
47
|
+
"typescript": "^5.5.0"
|
|
48
|
+
}
|
|
49
|
+
}
|