tokens-metric 0.1.0 → 0.2.3

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/README.md CHANGED
@@ -35,6 +35,19 @@ tokens-metric
35
35
 
36
36
  Press `q` (or `Esc`, or `Ctrl-C`) to quit.
37
37
 
38
+ ### Privacy defaults
39
+
40
+ Starting in v0.2.0, the TUI masks identifying information by default so screenshots can be shared safely:
41
+
42
+ - `cwd` paths are shown as `~/…` instead of `/Users/<you>/…`.
43
+ - The user ID is masked to `●●●●●●●●`.
44
+
45
+ Pass `--reveal` to show everything unmasked on your own machine:
46
+
47
+ ```bash
48
+ tokens-metric --reveal
49
+ ```
50
+
38
51
  ### Statusline inside Claude Code
39
52
 
40
53
  Add to `~/.claude/settings.json`:
@@ -0,0 +1,22 @@
1
+ export function parseArgs(argv) {
2
+ const args = argv.slice(2);
3
+ return {
4
+ reveal: args.includes('--reveal'),
5
+ help: args.includes('--help') || args.includes('-h'),
6
+ };
7
+ }
8
+ export const HELP_TEXT = `tokens-metric — real-time Claude Code token meter
9
+
10
+ Usage:
11
+ tokens-metric [--reveal] [--help]
12
+ tokens-metric-statusline [--reveal]
13
+
14
+ Options:
15
+ --reveal Show the full user ID and the unredacted cwd. By default these
16
+ are masked so screenshots don't expose identifying info.
17
+ -h, --help Show this help.
18
+
19
+ Privacy:
20
+ By default the UI shows ~/… instead of /Users/<you>/… and masks the user ID
21
+ to "●●●●●●●●". Pass --reveal to disable masking on your own machine.
22
+ `;
@@ -52,7 +52,7 @@ export function detectAuth() {
52
52
  loggedIn: false,
53
53
  authMethod: 'none',
54
54
  planHint: 'unknown',
55
- hint: 'Claude Code is installed but no userID was found in ~/.claude.json. Run `claude` to log in.',
55
+ hint: 'Claude Code is installed but you are not logged in. Run `claude` to log in.',
56
56
  };
57
57
  }
58
58
  return {
@@ -113,15 +113,15 @@ function inferPlanHint(cfg) {
113
113
  function planHintExplanation(plan, cfg) {
114
114
  switch (plan) {
115
115
  case 'team-or-enterprise':
116
- return 'Org-managed seat detected (cachedExtraUsageDisabledReason=org_level_disabled). Likely Team or Enterprise.';
116
+ return 'Org-managed account likely Team or Enterprise.';
117
117
  case 'paid':
118
- return 'Paid plan likely (Opus-on-Pro migration flag is set). Pro/Max not distinguishable locally.';
118
+ return 'Paid plan likely Pro or Max (not distinguishable locally).';
119
119
  case 'free':
120
120
  return cfg
121
- ? 'Logged in, but no paid-plan migration flag found. Likely Free (or very recently upgraded).'
121
+ ? 'Logged in. No paid-plan signals found likely Free.'
122
122
  : 'Logged in.';
123
123
  case 'api':
124
- return 'API key billing.';
124
+ return 'Using API key — pay-per-token.';
125
125
  case 'unknown':
126
126
  default:
127
127
  return 'Plan tier not determinable from local signals.';
@@ -0,0 +1,30 @@
1
+ import { homedir } from 'node:os';
2
+ /**
3
+ * Replace the user's home directory with `~` so screenshots posted publicly
4
+ * don't reveal the macOS username. Returns the input untouched if it doesn't
5
+ * start with $HOME.
6
+ */
7
+ export function anonymizePath(p) {
8
+ if (!p)
9
+ return '—';
10
+ const home = homedir();
11
+ if (p === home)
12
+ return '~';
13
+ if (p.startsWith(home + '/'))
14
+ return '~' + p.slice(home.length);
15
+ return p;
16
+ }
17
+ /**
18
+ * Mask all but the first 2 characters of the user ID with dots, so a
19
+ * screenshot still hints at "I'm logged in" without exposing the full hash.
20
+ * Pass `reveal` to bypass.
21
+ */
22
+ export function maskUserId(id, reveal) {
23
+ if (!id)
24
+ return undefined;
25
+ if (reveal)
26
+ return id;
27
+ if (id.length <= 2)
28
+ return '●●●●●●●●';
29
+ return id.slice(0, 2) + '●●●●●●';
30
+ }
@@ -3,6 +3,12 @@ import { aggregateTranscript, findActiveTranscript } from '../core/parser.js';
3
3
  import { detectAuth } from '../core/detect.js';
4
4
  import { estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
5
5
  import { totalTokens } from '../core/types.js';
6
+ import { HELP_TEXT, parseArgs } from '../core/args.js';
7
+ const OPTS = parseArgs(process.argv);
8
+ if (OPTS.help) {
9
+ process.stdout.write(HELP_TEXT);
10
+ process.exit(0);
11
+ }
6
12
  /**
7
13
  * One-shot status line. Reads whatever JSONL is currently the most recent
8
14
  * transcript, aggregates it, and prints a single line. Exits 0 always so
package/dist/tui/index.js CHANGED
@@ -7,7 +7,14 @@ import { tailTranscript } from '../core/tailer.js';
7
7
  import { detectAuth } from '../core/detect.js';
8
8
  import { estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
9
9
  import { totalTokens } from '../core/types.js';
10
+ import { anonymizePath, maskUserId } from '../core/privacy.js';
11
+ import { HELP_TEXT, parseArgs } from '../core/args.js';
10
12
  import { bar, sparkline } from './bars.js';
13
+ const OPTS = parseArgs(process.argv);
14
+ if (OPTS.help) {
15
+ process.stdout.write(HELP_TEXT);
16
+ process.exit(0);
17
+ }
11
18
  const RESCAN_MS = 3_000;
12
19
  const SPARK_WIDTH = 32;
13
20
  const BAR_WIDTH = 20;
@@ -51,6 +58,12 @@ function App() {
51
58
  handle.stop();
52
59
  return;
53
60
  }
61
+ // Seed initial state — tailTranscript already drained the file once
62
+ // before returning, but its first notify() fires before any listener
63
+ // is attached, so we'd otherwise wait for the next appended line.
64
+ lastTotalRef.current = totalTokens(handle.stats.totals);
65
+ lastSampleAtRef.current = Date.now();
66
+ setStats({ ...handle.stats });
54
67
  handle.onUpdate((s) => {
55
68
  const tot = totalTokens(s.totals);
56
69
  const delta = Math.max(0, tot - lastTotalRef.current);
@@ -91,7 +104,7 @@ function Header({ auth }) {
91
104
  const ok = auth.installed && auth.loggedIn;
92
105
  const dot = ok ? 'green' : auth.installed ? 'yellow' : 'red';
93
106
  const planChip = planChipFor(auth);
94
- return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "\u258C tokens-metric " }), _jsx(Text, { dimColor: true, children: "v0.1.0 \u2014 real-time Claude Code usage" })] }), _jsx(Box, { marginTop: 0, children: _jsxs(Text, { children: [_jsx(Text, { color: dot, children: "\u25CF" }), ' ', auth.installed ? 'Claude Code detected' : 'Claude Code NOT detected', ' ', _jsx(Text, { ...planChip.style, children: planChip.label }), auth.userIdShort && _jsx(Text, { dimColor: true, children: ` user ${auth.userIdShort}` })] }) }), auth.hint && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["\u21B3 ", auth.hint] }) }))] }));
107
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "\u258C tokens-metric " }), _jsx(Text, { dimColor: true, children: "v0.1.0 \u2014 real-time Claude Code usage" })] }), _jsx(Box, { marginTop: 0, children: _jsxs(Text, { children: [_jsx(Text, { color: dot, children: "\u25CF" }), ' ', auth.installed ? 'Claude Code detected' : 'Claude Code NOT detected', ' ', _jsx(Text, { ...planChip.style, children: planChip.label }), auth.userIdShort && (_jsx(Text, { dimColor: true, children: ` user ${maskUserId(auth.userIdShort, OPTS.reveal)}` }))] }) }), auth.hint && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["\u21B3 ", auth.hint] }) }))] }));
95
108
  }
96
109
  function planChipFor(auth) {
97
110
  if (!auth.loggedIn)
@@ -111,7 +124,7 @@ function planChipFor(auth) {
111
124
  }
112
125
  // ── Session panel ────────────────────────────────────────────────────────────
113
126
  function SessionPanel({ stats, rate, now, series, }) {
114
- return (_jsxs(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, flexDirection: "column", flexGrow: 1, minWidth: 42, children: [_jsx(Text, { bold: true, color: "green", children: "Active session" }), !stats ? (_jsx(Text, { dimColor: true, children: "Waiting for a Claude Code session\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(KV, { k: "Model", v: _jsx(Text, { color: "cyan", children: stats.lastModel ?? '—' }) }), _jsx(KV, { k: "Msgs ", v: _jsx(Text, { children: stats.messageCount }) }), _jsx(KV, { k: "Last ", v: stats.lastEventAt ? (_jsxs(Text, { children: [timeAgo(now - stats.lastEventAt), " ago"] })) : (_jsx(Text, { dimColor: true, children: "\u2014" })) }), _jsx(KV, { k: "cwd ", v: _jsx(Text, { dimColor: true, wrap: "truncate-middle", children: stats.cwd ?? '—' }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "\u03A3 " }), _jsx(Text, { color: "cyan", bold: true, children: fmtNumber(totalTokens(stats.totals)) }), _jsx(Text, { dimColor: true, children: " tokens" })] }), (() => {
127
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, flexDirection: "column", flexGrow: 1, minWidth: 42, children: [_jsx(Text, { bold: true, color: "green", children: "Active session" }), !stats ? (_jsx(Text, { dimColor: true, children: "Waiting for a Claude Code session\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(KV, { k: "Model", v: _jsx(Text, { color: "cyan", children: stats.lastModel ?? '—' }) }), _jsx(KV, { k: "Msgs ", v: _jsx(Text, { children: stats.messageCount }) }), _jsx(KV, { k: "Last ", v: stats.lastEventAt ? (_jsxs(Text, { children: [timeAgo(now - stats.lastEventAt), " ago"] })) : (_jsx(Text, { dimColor: true, children: "\u2014" })) }), _jsx(KV, { k: "cwd ", v: _jsx(Text, { dimColor: true, wrap: "truncate-middle", children: OPTS.reveal ? (stats.cwd ?? '—') : anonymizePath(stats.cwd) }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "\u03A3 " }), _jsx(Text, { color: "cyan", bold: true, children: fmtNumber(totalTokens(stats.totals)) }), _jsx(Text, { dimColor: true, children: " tokens" })] }), (() => {
115
128
  const cost = estimateCostUSD(stats.lastModel ?? '', stats.totals);
116
129
  return cost !== null ? (_jsxs(Text, { dimColor: true, children: ["~", fmtUSD(cost), " API-equivalent"] })) : null;
117
130
  })(), _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "~ " }), _jsx(Text, { color: "yellow", children: fmtNumber(Math.round(rate)) }), _jsx(Text, { dimColor: true, children: " tok/min" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["activity (last ", SPARK_WIDTH, "s)"] }), _jsx(Text, { color: "yellow", children: sparkline(series, SPARK_WIDTH) })] })] }))] }));
@@ -131,7 +144,7 @@ function BarRow({ label, value, max, color, }) {
131
144
  function TranscriptsPanel({ transcripts, activePath, now, }) {
132
145
  return (_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", width: "100%", children: [_jsx(Text, { bold: true, children: "Recent transcripts" }), transcripts.length === 0 ? (_jsx(Text, { dimColor: true, children: "None found under ~/.claude/projects" })) : (transcripts.map((t) => {
133
146
  const isActive = t.path === activePath;
134
- return (_jsxs(Text, { children: [_jsx(Text, { color: isActive ? 'green' : 'gray', children: isActive ? '▶ ' : ' ' }), _jsx(Text, { dimColor: !isActive, wrap: "truncate-middle", children: t.cwd }), _jsx(Text, { dimColor: true, children: ` ${timeAgo(now - t.mtimeMs)}` })] }, t.path));
147
+ return (_jsxs(Text, { children: [_jsx(Text, { color: isActive ? 'green' : 'gray', children: isActive ? '▶ ' : ' ' }), _jsx(Text, { dimColor: !isActive, wrap: "truncate-middle", children: OPTS.reveal ? t.cwd : anonymizePath(t.cwd) }), _jsx(Text, { dimColor: true, children: ` ${timeAgo(now - t.mtimeMs)}` })] }, t.path));
135
148
  }))] }));
136
149
  }
137
150
  // ── helpers ──────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokens-metric",
3
- "version": "0.1.0",
3
+ "version": "0.2.3",
4
4
  "description": "Real-time token usage meter for Claude Code — statusline + Ink TUI.",
5
5
  "type": "module",
6
6
  "bin": {