loopers-token-cli 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/dist/api.js ADDED
@@ -0,0 +1,26 @@
1
+ import { IngestPayload } from './schema.js';
2
+ export const CLI_VERSION = '0.1.0';
3
+ /** 전송 직전 페이로드를 스키마로 build(검증 + 스키마 외 필드 제거) */
4
+ export function buildPayload(config, stats) {
5
+ // parse는 스키마에 정의된 필드만 남기고 나머지를 제거한다 → 개인정보 차단.
6
+ return IngestPayload.parse({
7
+ schemaVersion: 1,
8
+ deviceId: config.deviceId,
9
+ nickname: config.nickname,
10
+ team: config.team,
11
+ cliVersion: CLI_VERSION,
12
+ stats,
13
+ });
14
+ }
15
+ export async function sendPayload(config, payload) {
16
+ const res = await fetch(config.endpoint, {
17
+ method: 'POST',
18
+ headers: {
19
+ 'content-type': 'application/json',
20
+ 'x-enroll-key': config.enrollKey,
21
+ },
22
+ body: JSON.stringify(payload),
23
+ });
24
+ const body = await res.text();
25
+ return { ok: res.ok, status: res.status, body };
26
+ }
@@ -0,0 +1,96 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { readdir, stat } from 'node:fs/promises';
4
+ import { aggregateFile, mergeBuckets } from './scanner.js';
5
+ export const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
6
+ /** 오늘(UTC) 일자 'YYYY-MM-DD'. Claude Code timestamp가 UTC이므로 UTC 기준으로 맞춘다. */
7
+ export function todayUtc(now) {
8
+ return now.toISOString().slice(0, 10);
9
+ }
10
+ /** ~/.claude/projects/**\/*.jsonl 전체 경로 수집 */
11
+ async function findSessionFiles() {
12
+ const out = [];
13
+ let entries;
14
+ try {
15
+ entries = await readdir(CLAUDE_PROJECTS_DIR);
16
+ }
17
+ catch {
18
+ return out; // projects 디렉토리 없음
19
+ }
20
+ for (const dir of entries) {
21
+ const full = join(CLAUDE_PROJECTS_DIR, dir);
22
+ let files;
23
+ try {
24
+ files = await readdir(full);
25
+ }
26
+ catch {
27
+ continue;
28
+ }
29
+ for (const f of files) {
30
+ if (f.endsWith('.jsonl'))
31
+ out.push(join(full, f));
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+ /**
37
+ * 스캔으로 stats 배열을 만든다.
38
+ *
39
+ * 전략(단순·견고):
40
+ * 1. mtime이 state와 같은 파일은 스킵(이미 처리됨).
41
+ * 2. 변경(또는 신규) 파일은 항상 처음부터(offset 0) 전체 재집계한다.
42
+ * 세션 파일은 보통 작아 재파싱 비용이 미미하고, 파일이 날짜 경계를 넘겨도
43
+ * 과거 일자 데이터가 누락되지 않는다.
44
+ * 3. dedupe는 파일(세션) 단위 Set으로 수행(message.id는 세션 단위 유일).
45
+ *
46
+ * 멱등성은 서버의 (device,date,model) 절대값 upsert가 보장한다. 같은 파일을
47
+ * N번 재집계해 보내도 결과는 동일하다.
48
+ *
49
+ * byteOffset 필드는 스키마 호환을 위해 유지하되 항상 0으로 둔다(전체 재집계 방식).
50
+ */
51
+ export async function collect(prevState, now) {
52
+ const files = await findSessionFiles();
53
+ const aggregated = new Map();
54
+ const nextFiles = { ...prevState.files };
55
+ let filesChanged = 0;
56
+ for (const filePath of files) {
57
+ let st;
58
+ try {
59
+ st = await stat(filePath);
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ const prev = prevState.files[filePath];
65
+ if (prev && prev.mtimeMs === st.mtimeMs)
66
+ continue; // 변경 없음
67
+ filesChanged++;
68
+ const seen = new Set();
69
+ const res = await aggregateFile(filePath, 0, seen);
70
+ mergeBuckets(aggregated, res.buckets);
71
+ nextFiles[filePath] = { byteOffset: 0, mtimeMs: st.mtimeMs };
72
+ }
73
+ const stats = [];
74
+ for (const [key, sums] of aggregated) {
75
+ const [date, model] = key.split('|');
76
+ stats.push({
77
+ date,
78
+ model,
79
+ inputTokens: sums.inputTokens,
80
+ outputTokens: sums.outputTokens,
81
+ cacheCreationInputTokens: sums.cacheCreationInputTokens,
82
+ cacheReadInputTokens: sums.cacheReadInputTokens,
83
+ messageCount: sums.messageCount,
84
+ });
85
+ }
86
+ return {
87
+ stats,
88
+ nextState: {
89
+ version: 1,
90
+ files: nextFiles,
91
+ lastSyncedAt: now.toISOString(),
92
+ },
93
+ filesScanned: files.length,
94
+ filesChanged,
95
+ };
96
+ }
@@ -0,0 +1,43 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { stdin, stdout } from 'node:process';
4
+ import { loadConfig, saveConfig, CONFIG_PATH } from '../config.js';
5
+ const DEFAULT_ENDPOINT = 'https://loopers-token-dashboard.vercel.app/api/ingest';
6
+ /** 비대화형(플래그) 또는 대화형으로 config를 생성/갱신한다. deviceId는 1회 생성 후 고정. */
7
+ export async function runInit(opts) {
8
+ const existing = await loadConfig();
9
+ const rl = createInterface({ input: stdin, output: stdout });
10
+ const ask = async (q, fallback) => {
11
+ const suffix = fallback ? ` [${fallback}]` : '';
12
+ const ans = (await rl.question(`${q}${suffix}: `)).trim();
13
+ return ans || fallback || '';
14
+ };
15
+ try {
16
+ console.log('\n루퍼스 토큰 대시보드 — 초기 설정');
17
+ console.log('⚠️ 닉네임/팀에 실명·이메일 등 개인정보를 넣지 마세요. 자유 라벨만 사용합니다.\n');
18
+ const nickname = opts.nickname ?? (await ask('닉네임', existing?.nickname));
19
+ const team = opts.team ?? (await ask('팀', existing?.team));
20
+ const endpoint = opts.endpoint ??
21
+ (await ask('서버 endpoint', existing?.endpoint ?? DEFAULT_ENDPOINT));
22
+ const enrollKey = opts.enrollKey ?? (await ask('등록 키(enroll key)', existing?.enrollKey));
23
+ if (!nickname || !team || !endpoint || !enrollKey) {
24
+ console.error('\n❌ 닉네임/팀/endpoint/등록 키는 모두 필요합니다.');
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ const config = {
29
+ deviceId: existing?.deviceId ?? randomUUID(),
30
+ nickname,
31
+ team,
32
+ endpoint,
33
+ enrollKey,
34
+ };
35
+ await saveConfig(config);
36
+ console.log(`\n✅ 설정 저장 완료: ${CONFIG_PATH}`);
37
+ console.log(` deviceId: ${config.deviceId}`);
38
+ console.log(' 이제 `loopers-token sync` 로 전송하세요.\n');
39
+ }
40
+ finally {
41
+ rl.close();
42
+ }
43
+ }
@@ -0,0 +1,47 @@
1
+ import { loadConfig } from '../config.js';
2
+ import { loadState, saveState } from '../state.js';
3
+ import { collect } from '../collect.js';
4
+ import { buildPayload, sendPayload } from '../api.js';
5
+ import { totalTokens } from '../schema.js';
6
+ export async function runSync(opts) {
7
+ const config = await loadConfig();
8
+ if (!config) {
9
+ console.error('❌ 설정이 없습니다. 먼저 `loopers-token init` 을 실행하세요.');
10
+ process.exitCode = 1;
11
+ return;
12
+ }
13
+ const log = (...a) => {
14
+ if (!opts.quiet)
15
+ console.log(...a);
16
+ };
17
+ const prevState = await loadState();
18
+ const now = new Date();
19
+ const { stats, nextState, filesScanned, filesChanged } = await collect(prevState, now);
20
+ if (stats.length === 0) {
21
+ log(`변경된 데이터 없음 (파일 ${filesScanned}개 스캔, ${filesChanged}개 변경). 전송 생략.`);
22
+ // 변경 파일이 있었으면(빈 결과여도) 커서는 갱신
23
+ if (filesChanged > 0)
24
+ await saveState(nextState);
25
+ return;
26
+ }
27
+ const payload = buildPayload(config, stats);
28
+ const grandTotal = stats.reduce((acc, s) => acc + totalTokens(s), 0);
29
+ const outTotal = stats.reduce((acc, s) => acc + s.outputTokens, 0);
30
+ log(`집계: ${stats.length} 버킷, 총 토큰 ${grandTotal.toLocaleString()} (output ${outTotal.toLocaleString()})`);
31
+ if (opts.dryRun) {
32
+ console.log('\n--- DRY RUN: 아래 페이로드를 전송할 예정입니다 (실제 전송 안 함) ---');
33
+ console.log(JSON.stringify(payload, null, 2));
34
+ console.log('--- 위 내용에 대화 원문/이름/이메일/cwd 가 없는지 확인하세요 ---\n');
35
+ return; // dry-run 은 state를 갱신하지 않는다
36
+ }
37
+ log(`전송 → ${config.endpoint}`);
38
+ const res = await sendPayload(config, payload);
39
+ if (res.ok) {
40
+ log(`✅ 전송 성공 (HTTP ${res.status}). ${res.body}`);
41
+ await saveState(nextState);
42
+ }
43
+ else {
44
+ console.error(`❌ 전송 실패 (HTTP ${res.status}). ${res.body}`);
45
+ process.exitCode = 1;
46
+ }
47
+ }
package/dist/config.js ADDED
@@ -0,0 +1,23 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
4
+ export const CONFIG_DIR = join(homedir(), '.config', 'loopers-token');
5
+ export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
6
+ export const STATE_PATH = join(CONFIG_DIR, 'state.json');
7
+ export async function ensureConfigDir() {
8
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
9
+ }
10
+ export async function loadConfig() {
11
+ try {
12
+ const raw = await readFile(CONFIG_PATH, 'utf8');
13
+ return JSON.parse(raw);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export async function saveConfig(c) {
20
+ await ensureConfigDir();
21
+ await writeFile(CONFIG_PATH, JSON.stringify(c, null, 2), 'utf8');
22
+ await chmod(CONFIG_PATH, 0o600);
23
+ }
package/dist/index.js ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { runInit } from './commands/init.js';
4
+ import { runSync } from './commands/sync.js';
5
+ import { CLI_VERSION } from './api.js';
6
+ const program = new Command();
7
+ program
8
+ .name('loopers-token')
9
+ .description('루퍼스 토큰 대시보드 수집 CLI — ~/.claude 세션의 토큰 집계값만 전송')
10
+ .version(CLI_VERSION);
11
+ program
12
+ .command('init')
13
+ .description('닉네임/팀/서버 설정 (deviceId는 1회 생성 후 고정)')
14
+ .option('--nickname <name>', '닉네임 (실명/이메일 금지)')
15
+ .option('--team <team>', '팀 라벨')
16
+ .option('--endpoint <url>', '서버 ingest endpoint')
17
+ .option('--enroll-key <key>', '부트캠프 공통 등록 키')
18
+ .action((opts) => runInit({
19
+ nickname: opts.nickname,
20
+ team: opts.team,
21
+ endpoint: opts.endpoint,
22
+ enrollKey: opts.enrollKey,
23
+ }));
24
+ program
25
+ .command('sync')
26
+ .description('~/.claude 세션을 스캔해 집계값을 서버로 전송')
27
+ .option('--dry-run', '전송하지 않고 페이로드만 출력 (프라이버시 확인용)')
28
+ .option('--quiet', '로그 최소화 (자동 스케줄용)')
29
+ .action((opts) => runSync({ dryRun: opts.dryRun, quiet: opts.quiet }));
30
+ program.parseAsync(process.argv);
@@ -0,0 +1,94 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { stat } from 'node:fs/promises';
4
+ function zero() {
5
+ return {
6
+ inputTokens: 0,
7
+ outputTokens: 0,
8
+ cacheCreationInputTokens: 0,
9
+ cacheReadInputTokens: 0,
10
+ messageCount: 0,
11
+ };
12
+ }
13
+ /**
14
+ * 한 .jsonl 세션 파일을 fromByteOffset부터 읽어 토큰을 집계한다.
15
+ *
16
+ * 핵심 규칙:
17
+ * - type === 'assistant' 줄의 message.usage 만 본다.
18
+ * - message.content 는 절대 파싱/접근하지 않는다 (프라이버시).
19
+ * - message.id(없으면 requestId) 기준 dedupe — 동일 id 줄은 usage 값이
20
+ * 완전히 동일하게 복제되므로 한 번만 집계한다. (안 하면 ~1.9배 과다 집계)
21
+ * - model === '<synthetic>' 줄은 제외.
22
+ *
23
+ * @param seenMessageIds 이 파일(세션) 내 이미 집계한 message.id 집합. 호출자가
24
+ * 파일 단위로 새로 만들어 넘긴다 (message.id는 세션 단위 유일).
25
+ */
26
+ export async function aggregateFile(filePath, fromByteOffset, seenMessageIds) {
27
+ const buckets = new Map();
28
+ let maxTimestamp = null;
29
+ const { size } = await stat(filePath);
30
+ if (fromByteOffset >= size) {
31
+ return { buckets, newByteOffset: size, maxTimestamp };
32
+ }
33
+ const stream = createReadStream(filePath, {
34
+ start: fromByteOffset,
35
+ encoding: 'utf8',
36
+ });
37
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
38
+ for await (const line of rl) {
39
+ if (!line.trim())
40
+ continue;
41
+ let o;
42
+ try {
43
+ o = JSON.parse(line);
44
+ }
45
+ catch {
46
+ continue; // 잘린/손상된 줄 무시
47
+ }
48
+ if (o?.type !== 'assistant')
49
+ continue;
50
+ const m = o.message;
51
+ if (!m?.usage || !m.model)
52
+ continue;
53
+ if (m.model === '<synthetic>')
54
+ continue;
55
+ const id = m.id ?? o.requestId;
56
+ if (!id || seenMessageIds.has(id))
57
+ continue; // ★ dedupe
58
+ seenMessageIds.add(id);
59
+ const ts = o.timestamp;
60
+ const date = typeof ts === 'string' ? ts.slice(0, 10) : null;
61
+ if (!date)
62
+ continue;
63
+ if (!maxTimestamp || (ts && ts > maxTimestamp))
64
+ maxTimestamp = ts;
65
+ const key = `${date}|${m.model}`;
66
+ let b = buckets.get(key);
67
+ if (!b) {
68
+ b = zero();
69
+ buckets.set(key, b);
70
+ }
71
+ const u = m.usage;
72
+ b.inputTokens += u.input_tokens ?? 0;
73
+ b.outputTokens += u.output_tokens ?? 0;
74
+ b.cacheCreationInputTokens += u.cache_creation_input_tokens ?? 0;
75
+ b.cacheReadInputTokens += u.cache_read_input_tokens ?? 0;
76
+ b.messageCount += 1;
77
+ }
78
+ return { buckets, newByteOffset: size, maxTimestamp };
79
+ }
80
+ /** 두 버킷맵을 합산(in-place로 target에 source를 더한다) */
81
+ export function mergeBuckets(target, source) {
82
+ for (const [key, s] of source) {
83
+ let t = target.get(key);
84
+ if (!t) {
85
+ t = zero();
86
+ target.set(key, t);
87
+ }
88
+ t.inputTokens += s.inputTokens;
89
+ t.outputTokens += s.outputTokens;
90
+ t.cacheCreationInputTokens += s.cacheCreationInputTokens;
91
+ t.cacheReadInputTokens += s.cacheReadInputTokens;
92
+ t.messageCount += s.messageCount;
93
+ }
94
+ }
package/dist/schema.js ADDED
@@ -0,0 +1,43 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * 수집 페이로드 스키마 — 프라이버시 차단의 구조적 근간.
4
+ * (apps/web 의 동일 스키마와 동기화 유지. CLI 단독 퍼블리시를 위해 인라인 복사본.)
5
+ *
6
+ * 여기에는 의도적으로 다음 필드가 존재하지 않는다:
7
+ * - 원본 대화 내용(message.content)
8
+ * - 이름 / 이메일 / API 키 / 토큰 등 개인·인증 정보
9
+ * - cwd / gitBranch / sessionId 등 작업 맥락
10
+ */
11
+ /** 'YYYY-MM-DD' 형식의 UTC 일자 */
12
+ export const dateString = z
13
+ .string()
14
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be YYYY-MM-DD');
15
+ /** (날짜, 모델) 단위의 토큰 집계값 — 숫자만 담는다 */
16
+ export const DailyModelStat = z.object({
17
+ date: dateString,
18
+ model: z.string().min(1).max(64),
19
+ inputTokens: z.number().int().nonnegative(),
20
+ outputTokens: z.number().int().nonnegative(),
21
+ cacheCreationInputTokens: z.number().int().nonnegative(),
22
+ cacheReadInputTokens: z.number().int().nonnegative(),
23
+ messageCount: z.number().int().nonnegative(),
24
+ });
25
+ /** 닉네임/팀 — 자유 라벨. 실명/이메일 입력 금지 */
26
+ export const labelString = z.string().trim().min(1).max(32);
27
+ /** CLI가 중앙 서버 POST /api/ingest 로 보내는 전체 페이로드 */
28
+ export const IngestPayload = z.object({
29
+ schemaVersion: z.literal(1),
30
+ deviceId: z.string().uuid(),
31
+ nickname: labelString,
32
+ team: labelString,
33
+ cliVersion: z.string().max(20),
34
+ stats: z.array(DailyModelStat).max(2000),
35
+ });
36
+ /** 한 stat의 "총 토큰"(input + output + cacheCreation + cacheRead) */
37
+ export function totalTokens(s) {
38
+ return (s.inputTokens +
39
+ s.outputTokens +
40
+ s.cacheCreationInputTokens +
41
+ s.cacheReadInputTokens);
42
+ }
43
+ export const SCHEMA_VERSION = 1;
package/dist/state.js ADDED
@@ -0,0 +1,22 @@
1
+ import { readFile, writeFile, chmod } from 'node:fs/promises';
2
+ import { STATE_PATH, ensureConfigDir } from './config.js';
3
+ export function emptyState() {
4
+ return { version: 1, files: {}, lastSyncedAt: null };
5
+ }
6
+ export async function loadState() {
7
+ try {
8
+ const raw = await readFile(STATE_PATH, 'utf8');
9
+ const parsed = JSON.parse(raw);
10
+ if (parsed?.version === 1 && parsed.files)
11
+ return parsed;
12
+ return emptyState();
13
+ }
14
+ catch {
15
+ return emptyState();
16
+ }
17
+ }
18
+ export async function saveState(s) {
19
+ await ensureConfigDir();
20
+ await writeFile(STATE_PATH, JSON.stringify(s), 'utf8');
21
+ await chmod(STATE_PATH, 0o600);
22
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "loopers-token-cli",
3
+ "version": "0.1.0",
4
+ "description": "루퍼스 토큰 대시보드 수집 CLI — ~/.claude 세션의 토큰 집계값만 전송",
5
+ "type": "module",
6
+ "bin": {
7
+ "loopers-token": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/Loopers-dev-lab/loopers-token-dashboard.git",
19
+ "directory": "packages/cli"
20
+ },
21
+ "keywords": [
22
+ "claude",
23
+ "claude-code",
24
+ "token",
25
+ "usage",
26
+ "dashboard",
27
+ "loopers"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.json",
34
+ "prepublishOnly": "npm run build",
35
+ "start": "node --experimental-strip-types src/index.ts",
36
+ "dev": "node --experimental-strip-types src/index.ts"
37
+ },
38
+ "dependencies": {
39
+ "commander": "^12.1.0",
40
+ "zod": "^3.23.8"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.5.0",
44
+ "typescript": "^5.5.4"
45
+ }
46
+ }