ai-heatmap 1.14.6 → 1.16.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/README.md CHANGED
@@ -183,6 +183,9 @@ npx ai-heatmap update
183
183
  For automated updates, use a local cron job or macOS LaunchAgent:
184
184
 
185
185
  ```bash
186
+ CLAUDE_CODE_OAUTH_TOKEN=sk-ant-xxx
187
+ GH_TOKEN=ghp_xxx
188
+
186
189
  0 0 * * * npx --yes ai-heatmap@latest update
187
190
  ```
188
191
 
@@ -196,6 +199,10 @@ npx --yes ai-heatmap@latest update
196
199
 
197
200
  ## Deployment
198
201
 
202
+ ```bash
203
+ npm publish
204
+ ```
205
+
199
206
  ### GitHub Pages
200
207
 
201
208
  1. Enable GitHub Pages (Settings > Pages > Source: GitHub Actions)
package/bin/cli.mjs CHANGED
@@ -1,12 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
4
4
  import { resolve, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import os from "node:os";
6
7
 
7
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
9
  const root = resolve(__dirname, "..");
9
10
 
11
+ function getMachineName() {
12
+ try {
13
+ if (process.platform === "darwin") {
14
+ const serial = execSync(
15
+ "ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformSerialNumber/ {print $NF}' | tr -d '\"'",
16
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] },
17
+ ).trim();
18
+ if (serial) return serial;
19
+ } else if (process.platform === "linux") {
20
+ const id = readFileSync("/etc/machine-id", "utf-8").trim();
21
+ if (id) return id.slice(0, 12);
22
+ } else if (process.platform === "win32") {
23
+ const uuid = execSync("wmic csproduct get UUID /value", {
24
+ encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"],
25
+ }).match(/UUID=([^\r\n]+)/)?.[1]?.trim();
26
+ if (uuid) return uuid.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12);
27
+ }
28
+ } catch {}
29
+ return os.hostname().replace(/[^a-zA-Z0-9_-]/g, "_");
30
+ }
31
+
10
32
  const [command, ...args] = process.argv.slice(2);
11
33
 
12
34
  const HELP = `
@@ -15,12 +37,14 @@ ai-heatmap - AI usage cost heatmap
15
37
  Commands:
16
38
  init [--repo <name>] Create a new heatmap repo and generate initial data
17
39
  update [--repo <owner/repo>] Generate data + push to repo
40
+ [--name <machine>]
18
41
  generate-svg Generate heatmap.svg from data.json
19
42
  delete [--repo <name>] Delete the heatmap GitHub repo
20
43
  deploy [--repo <name>] Deploy to Vercel (SVG API endpoint)
21
44
 
22
45
  Options:
23
46
  --repo <name> Target repo (default: {user}-ai-heatmap)
47
+ --name <machine> Machine name for per-device data file (default: serial number)
24
48
  --since YYYYMMDD Start date (update only)
25
49
  --until YYYYMMDD End date (update only)
26
50
 
@@ -28,6 +52,7 @@ Examples:
28
52
  npx ai-heatmap init
29
53
  npx ai-heatmap init --repo my-heatmap
30
54
  npx ai-heatmap update
55
+ npx ai-heatmap update --name=mac
31
56
  npx ai-heatmap update --repo owner/repo-name
32
57
  npx ai-heatmap delete
33
58
  npx ai-heatmap deploy
@@ -38,6 +63,45 @@ function getArg(flag) {
38
63
  return idx !== -1 && args[idx + 1] ? args[idx + 1] : null;
39
64
  }
40
65
 
66
+ function resolveRepo(repoArg) {
67
+ try {
68
+ const owner = execSync("gh api user --jq .login", { encoding: "utf-8" }).trim();
69
+ if (!repoArg) return `${owner}/${owner}-ai-heatmap`;
70
+ if (!repoArg.includes("/")) return `${owner}/${repoArg}`;
71
+ return repoArg;
72
+ } catch {
73
+ if (repoArg && repoArg.includes("/")) return repoArg;
74
+ console.error("Could not determine repo. Use --repo <owner/repo>");
75
+ process.exit(1);
76
+ }
77
+ }
78
+
79
+ function pushFile(repo, repoPath, localPath) {
80
+ const content = readFileSync(localPath, "utf-8");
81
+ const base64 = Buffer.from(content).toString("base64");
82
+
83
+ let sha = "";
84
+ try {
85
+ sha = execSync(
86
+ `gh api repos/${repo}/contents/${repoPath} --jq .sha`,
87
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] },
88
+ ).trim();
89
+ } catch { /* file doesn't exist yet */ }
90
+
91
+ const payload = {
92
+ message: `Update ${repoPath} (${new Date().toISOString().slice(0, 10)})`,
93
+ content: base64,
94
+ };
95
+ if (sha) payload.sha = sha;
96
+
97
+ const payloadStr = JSON.stringify(JSON.stringify(payload));
98
+ execSync(
99
+ `echo ${payloadStr} | gh api repos/${repo}/contents/${repoPath} -X PUT --input -`,
100
+ { stdio: ["pipe", "inherit", "inherit"] },
101
+ );
102
+ console.log(` Pushed ${repoPath} to ${repo}`);
103
+ }
104
+
41
105
  switch (command) {
42
106
  case "init": {
43
107
  const script = resolve(__dirname, "init.mjs");
@@ -47,13 +111,57 @@ switch (command) {
47
111
  }
48
112
  case "update": {
49
113
  const genScript = resolve(root, "scripts/generate.mjs");
50
- const pushScript = resolve(__dirname, "push.mjs");
114
+ const outDir = resolve(root, "public");
51
115
  const genArgs = args.filter(
52
- (a) => a.startsWith("--since") || a.startsWith("--until"),
116
+ (a) => a.startsWith("--since") || a.startsWith("--until") || a.startsWith("--name"),
53
117
  );
54
- const pushArgs = args.filter((a) => a.startsWith("--repo"));
118
+
119
+ const repo = resolveRepo(getArg("--repo"));
120
+
121
+ // 머신 이름 결정 (generate.mjs와 동일한 로직)
122
+ const nameFlag = genArgs.find((a) => a.startsWith("--name="));
123
+ const machineName = nameFlag
124
+ ? nameFlag.slice("--name=".length)
125
+ : getMachineName();
126
+
127
+ // 1. GitHub API로 다른 컴퓨터의 data-*.json 파일들을 로컬에 다운로드
128
+ console.log(`Fetching machine data files from ${repo}...`);
129
+ mkdirSync(outDir, { recursive: true });
130
+ try {
131
+ const raw = execSync(
132
+ `gh api repos/${repo}/contents/public --jq '[.[] | select(.name | test("^data-.+\\.json$"))]'`,
133
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] },
134
+ );
135
+ const files = JSON.parse(raw);
136
+ for (const file of files) {
137
+ // 현재 컴퓨터 파일은 generate에서 새로 생성하므로 건너뜀
138
+ if (file.name === `data-${machineName}.json`) continue;
139
+ const decoded = Buffer.from(file.content.replace(/\n/g, ""), "base64").toString("utf-8");
140
+ writeFileSync(resolve(outDir, file.name), decoded);
141
+ console.log(` Fetched ${file.name}`);
142
+ }
143
+ } catch {
144
+ console.log(" No existing machine data files found (first run).");
145
+ }
146
+
147
+ // 2. generate: 이 컴퓨터 데이터 수집 + 모든 data-*.json 합산 → data.json 생성
55
148
  execSync(`node ${genScript} ${genArgs.join(" ")}`, { stdio: "inherit" });
56
- execSync(`node ${pushScript} ${pushArgs.join(" ")}`, { stdio: "inherit" });
149
+
150
+ // 3. data-{name}.json push (이 컴퓨터 개별 파일)
151
+ const machineFile = `data-${machineName}.json`;
152
+ const machineFilePath = resolve(outDir, machineFile);
153
+ if (existsSync(machineFilePath)) {
154
+ console.log(`Pushing machine data file...`);
155
+ pushFile(repo, `public/${machineFile}`, machineFilePath);
156
+ }
157
+
158
+ // 4. data.json push (합산 결과)
159
+ const dataPath = resolve(outDir, "data.json");
160
+ if (existsSync(dataPath)) {
161
+ console.log(`Pushing merged data.json...`);
162
+ pushFile(repo, "public/data.json", dataPath);
163
+ }
164
+
57
165
  break;
58
166
  }
59
167
  case "delete": {
package/bin/init.mjs CHANGED
@@ -129,6 +129,9 @@ readmeLines.push(
129
129
  "### Cron (daily update)",
130
130
  "",
131
131
  "```bash",
132
+ "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-xxx",
133
+ "GH_TOKEN=ghp_xxx",
134
+ "",
132
135
  "0 0 * * * npx --yes ai-heatmap@latest update",
133
136
  "```",
134
137
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-heatmap",
3
- "version": "1.14.6",
3
+ "version": "1.16.0",
4
4
  "description": "AI usage cost heatmap powered by ccusage + react-activity-calendar",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,15 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync } from "node:child_process";
3
- import { writeFileSync, mkdirSync } from "node:fs";
3
+ import { writeFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { resolve, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import os from "node:os";
6
7
 
7
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
9
  const root = resolve(__dirname, "..");
9
10
 
11
+ function getMachineName() {
12
+ try {
13
+ if (process.platform === "darwin") {
14
+ const serial = execSync(
15
+ "ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformSerialNumber/ {print $NF}' | tr -d '\"'",
16
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] },
17
+ ).trim();
18
+ if (serial) return serial;
19
+ } else if (process.platform === "linux") {
20
+ const id = readFileSync("/etc/machine-id", "utf-8").trim();
21
+ if (id) return id.slice(0, 12);
22
+ } else if (process.platform === "win32") {
23
+ const uuid = execSync("wmic csproduct get UUID /value", {
24
+ encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"],
25
+ }).match(/UUID=([^\r\n]+)/)?.[1]?.trim();
26
+ if (uuid) return uuid.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12);
27
+ }
28
+ } catch {}
29
+ return os.hostname().replace(/[^a-zA-Z0-9_-]/g, "_");
30
+ }
31
+
10
32
  const args = process.argv.slice(2);
11
33
  const sinceFlag = args.find((a) => a.startsWith("--since"));
12
34
  const untilFlag = args.find((a) => a.startsWith("--until"));
35
+ const nameFlag = args.find((a) => a.startsWith("--name="));
36
+ const machineName = nameFlag
37
+ ? nameFlag.slice("--name=".length)
38
+ : getMachineName();
13
39
 
14
40
  let cmd = "npx --yes ccusage@latest daily --json";
15
41
  if (sinceFlag) cmd += ` ${sinceFlag}`;
@@ -22,9 +48,9 @@ const { daily } = JSON.parse(raw);
22
48
  const costs = daily.map((d) => d.totalCost);
23
49
  const maxCost = Math.max(...costs);
24
50
 
25
- function toLevel(cost) {
26
- if (cost === 0 || maxCost === 0) return 0;
27
- const ratio = cost / maxCost;
51
+ function toLevel(cost, max) {
52
+ if (cost === 0 || max === 0) return 0;
53
+ const ratio = cost / max;
28
54
  if (ratio <= 0.25) return 1;
29
55
  if (ratio <= 0.5) return 2;
30
56
  if (ratio <= 0.75) return 3;
@@ -43,17 +69,21 @@ for (let i = 364; i >= 0; i--) {
43
69
  const date = d.toISOString().slice(0, 10);
44
70
  const entry = dataMap.get(date);
45
71
  if (entry) {
46
- const cacheTotal = entry.cacheCreationTokens + entry.cacheReadTokens;
72
+ const cacheReadTokens = entry.cacheReadTokens ?? 0;
73
+ const cacheCreationTokens = entry.cacheCreationTokens ?? 0;
74
+ const cacheTotal = cacheCreationTokens + cacheReadTokens;
47
75
  const cacheHitRate = cacheTotal > 0
48
- ? Math.round((entry.cacheReadTokens / cacheTotal) * 100)
76
+ ? Math.round((cacheReadTokens / cacheTotal) * 100)
49
77
  : 0;
50
78
  activities.push({
51
79
  date,
52
80
  count: Math.round(entry.totalCost * 100) / 100,
53
- level: toLevel(entry.totalCost),
81
+ level: toLevel(entry.totalCost, maxCost),
54
82
  inputTokens: entry.inputTokens,
55
83
  outputTokens: entry.outputTokens,
56
84
  totalTokens: entry.totalTokens,
85
+ cacheReadTokens,
86
+ cacheCreationTokens,
57
87
  cacheHitRate,
58
88
  modelsUsed: entry.modelsUsed,
59
89
  modelBreakdowns: entry.modelBreakdowns.map((m) => ({
@@ -68,6 +98,80 @@ for (let i = 364; i >= 0; i--) {
68
98
 
69
99
  const outDir = resolve(root, "public");
70
100
  mkdirSync(outDir, { recursive: true });
101
+
102
+ // 1. 컴퓨터별 개별 파일 저장
103
+ const machineFile = resolve(outDir, `data-${machineName}.json`);
104
+ writeFileSync(machineFile, JSON.stringify(activities, null, 2));
105
+ console.log(`Generated ${machineFile} (${activities.length} days)`);
106
+
107
+ // 2. 모든 data-{name}.json 파일을 읽어서 합산
108
+ const dataFiles = readdirSync(outDir)
109
+ .filter((f) => f.match(/^data-.+\.json$/))
110
+ .sort()
111
+ .map((f) => resolve(outDir, f));
112
+
113
+ console.log(`Merging ${dataFiles.length} file(s): ${dataFiles.map((f) => f.split("/").pop()).join(", ")}`);
114
+
115
+ const mergeMap = new Map();
116
+
117
+ for (const file of dataFiles) {
118
+ const fileData = JSON.parse(readFileSync(file, "utf-8"));
119
+ for (const entry of fileData) {
120
+ if (!mergeMap.has(entry.date)) {
121
+ mergeMap.set(entry.date, {
122
+ date: entry.date,
123
+ count: 0,
124
+ inputTokens: 0,
125
+ outputTokens: 0,
126
+ totalTokens: 0,
127
+ cacheReadTokens: 0,
128
+ cacheCreationTokens: 0,
129
+ modelBreakdowns: new Map(),
130
+ });
131
+ }
132
+ const m = mergeMap.get(entry.date);
133
+ m.count = Math.round((m.count + (entry.count ?? 0)) * 100) / 100;
134
+ m.inputTokens += entry.inputTokens ?? 0;
135
+ m.outputTokens += entry.outputTokens ?? 0;
136
+ m.totalTokens += entry.totalTokens ?? 0;
137
+ m.cacheReadTokens += entry.cacheReadTokens ?? 0;
138
+ m.cacheCreationTokens += entry.cacheCreationTokens ?? 0;
139
+
140
+ for (const mb of (entry.modelBreakdowns ?? [])) {
141
+ const prev = m.modelBreakdowns.get(mb.model) ?? 0;
142
+ m.modelBreakdowns.set(mb.model, Math.round((prev + mb.cost) * 100) / 100);
143
+ }
144
+ }
145
+ }
146
+
147
+ // 합산된 최대값 기준으로 level 재계산
148
+ const allCounts = [...mergeMap.values()].map((m) => m.count);
149
+ const mergedMax = Math.max(...allCounts);
150
+
151
+ const merged = [...mergeMap.values()]
152
+ .sort((a, b) => a.date.localeCompare(b.date))
153
+ .map((m) => {
154
+ const cacheTotal = m.cacheReadTokens + m.cacheCreationTokens;
155
+ const result = {
156
+ date: m.date,
157
+ count: m.count,
158
+ level: toLevel(m.count, mergedMax),
159
+ };
160
+ if (m.inputTokens || m.outputTokens || m.totalTokens) {
161
+ result.inputTokens = m.inputTokens;
162
+ result.outputTokens = m.outputTokens;
163
+ result.totalTokens = m.totalTokens;
164
+ }
165
+ if (cacheTotal > 0) {
166
+ result.cacheHitRate = Math.round((m.cacheReadTokens / cacheTotal) * 100);
167
+ }
168
+ const mbs = [...m.modelBreakdowns.entries()].map(([model, cost]) => ({ model, cost }));
169
+ if (mbs.length > 0) {
170
+ result.modelBreakdowns = mbs;
171
+ }
172
+ return result;
173
+ });
174
+
71
175
  const outPath = resolve(outDir, "data.json");
72
- writeFileSync(outPath, JSON.stringify(activities, null, 2));
73
- console.log(`Generated ${outPath} (${activities.length} days)`);
176
+ writeFileSync(outPath, JSON.stringify(merged, null, 2));
177
+ console.log(`Merged into ${outPath} (${merged.length} days)`);