@ipv9/tokentracker-cli 0.39.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.
Files changed (192) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +464 -0
  3. package/README.ko.md +464 -0
  4. package/README.md +476 -0
  5. package/README.zh-CN.md +469 -0
  6. package/bin/tracker.js +52 -0
  7. package/dashboard/dist/app-icon.png +0 -0
  8. package/dashboard/dist/apple-touch-icon.png +0 -0
  9. package/dashboard/dist/assets/ActivityHeatmap-BxBGHY1z.js +42 -0
  10. package/dashboard/dist/assets/Card-GROxUc_6.js +1 -0
  11. package/dashboard/dist/assets/DashboardPage-BZ5VFoBk.js +19 -0
  12. package/dashboard/dist/assets/DevicePage-CQts7ivB.js +1 -0
  13. package/dashboard/dist/assets/DialogTitle-DqFsk26U.js +1 -0
  14. package/dashboard/dist/assets/FadeIn-HBBXW_3q.js +1 -0
  15. package/dashboard/dist/assets/HeaderGithubStar-COD2HfaF.js +1 -0
  16. package/dashboard/dist/assets/IpCheckPage-Cn0l8-BQ.js +15 -0
  17. package/dashboard/dist/assets/LandingPage-CmV1BMU4.js +4356 -0
  18. package/dashboard/dist/assets/LeaderboardAvatar-CiIpWZwb.js +1 -0
  19. package/dashboard/dist/assets/LeaderboardPage-BU0mlR3g.js +6 -0
  20. package/dashboard/dist/assets/LeaderboardProfileModal-C49_3Xm9.js +72 -0
  21. package/dashboard/dist/assets/LeaderboardProfilePage-Bh1n0BAM.js +1 -0
  22. package/dashboard/dist/assets/LimitsPage-fZAmwLWT.js +2 -0
  23. package/dashboard/dist/assets/LocalOnlyNotice-D3rQdA-P.js +1 -0
  24. package/dashboard/dist/assets/LoginPage-enO4XP-c.js +1 -0
  25. package/dashboard/dist/assets/PopoverPopup-pitJEpN1.js +1 -0
  26. package/dashboard/dist/assets/Select-tT9x4ntD.js +1 -0
  27. package/dashboard/dist/assets/SelectItemText-C7F7eIm0.js +1 -0
  28. package/dashboard/dist/assets/SettingsPage-pDasZNU2.js +1 -0
  29. package/dashboard/dist/assets/SkillsPage-DEu7FY-2.js +1 -0
  30. package/dashboard/dist/assets/WidgetsPage-DGcO7rbp.js +1 -0
  31. package/dashboard/dist/assets/WrappedPage-D_1ID213.js +1 -0
  32. package/dashboard/dist/assets/agent-logos-DXQVg1xy.js +1 -0
  33. package/dashboard/dist/assets/arrow-up-right-DNLo92Ba.js +1 -0
  34. package/dashboard/dist/assets/download-DbBBBpJF.js +1 -0
  35. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
  36. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
  37. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
  38. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
  39. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
  40. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
  41. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-BKC3PZkM.woff +0 -0
  42. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-DdrQ8KBw.woff2 +0 -0
  43. package/dashboard/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
  44. package/dashboard/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
  45. package/dashboard/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
  46. package/dashboard/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
  47. package/dashboard/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
  48. package/dashboard/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
  49. package/dashboard/dist/assets/geist-mono-latin-900-normal-CmoKXrdK.woff +0 -0
  50. package/dashboard/dist/assets/geist-mono-latin-900-normal-Cu5MFKsu.woff2 +0 -0
  51. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
  52. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
  53. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
  54. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
  55. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
  56. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
  57. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-Br6WNUGb.woff +0 -0
  58. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-CrmBTzU2.woff2 +0 -0
  59. package/dashboard/dist/assets/index-BeoRn2gJ.js +2 -0
  60. package/dashboard/dist/assets/info-Bh5qTz6k.js +1 -0
  61. package/dashboard/dist/assets/main-BIY3Nj6I.js +969 -0
  62. package/dashboard/dist/assets/main-G_F9Iu_x.css +1 -0
  63. package/dashboard/dist/assets/use-limits-display-prefs-B4iJ-bBc.js +1 -0
  64. package/dashboard/dist/assets/use-native-settings-DHjPFDv8.js +1 -0
  65. package/dashboard/dist/assets/use-usage-limits-odqe9OY7.js +1 -0
  66. package/dashboard/dist/assets/useCurrency-BsSZiZMP.js +1 -0
  67. package/dashboard/dist/assets/useOpenInteractionType-BQhgq4rv.js +12 -0
  68. package/dashboard/dist/brand-logos/antigravity.svg +1 -0
  69. package/dashboard/dist/brand-logos/claude-code.svg +1 -0
  70. package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
  71. package/dashboard/dist/brand-logos/codex.svg +15 -0
  72. package/dashboard/dist/brand-logos/copilot.svg +1 -0
  73. package/dashboard/dist/brand-logos/cursor.svg +1 -0
  74. package/dashboard/dist/brand-logos/gemini.svg +1 -0
  75. package/dashboard/dist/brand-logos/grok.svg +1 -0
  76. package/dashboard/dist/brand-logos/hermes.svg +1 -0
  77. package/dashboard/dist/brand-logos/kilo.svg +4 -0
  78. package/dashboard/dist/brand-logos/kimi.svg +1 -0
  79. package/dashboard/dist/brand-logos/kiro.svg +1 -0
  80. package/dashboard/dist/brand-logos/openclaw.svg +22 -0
  81. package/dashboard/dist/brand-logos/opencode.svg +3 -0
  82. package/dashboard/dist/clawd/happy.svg +161 -0
  83. package/dashboard/dist/clawd/idle/collapse.svg +101 -0
  84. package/dashboard/dist/clawd/idle/doze.svg +72 -0
  85. package/dashboard/dist/clawd/idle/follow.svg +64 -0
  86. package/dashboard/dist/clawd/idle/living.svg +196 -0
  87. package/dashboard/dist/clawd/idle/look.svg +115 -0
  88. package/dashboard/dist/clawd/idle/yawn.svg +158 -0
  89. package/dashboard/dist/clawd/mini/alert.svg +129 -0
  90. package/dashboard/dist/clawd/mini/crabwalk.svg +76 -0
  91. package/dashboard/dist/clawd/mini/enter-sleep.svg +65 -0
  92. package/dashboard/dist/clawd/mini/enter.svg +115 -0
  93. package/dashboard/dist/clawd/mini/happy.svg +124 -0
  94. package/dashboard/dist/clawd/mini/idle-tight.svg +15 -0
  95. package/dashboard/dist/clawd/mini/idle.svg +83 -0
  96. package/dashboard/dist/clawd/mini/peek.svg +82 -0
  97. package/dashboard/dist/clawd/mini/sleep.svg +112 -0
  98. package/dashboard/dist/clawd/react/double.svg +108 -0
  99. package/dashboard/dist/clawd/react/drag.svg +102 -0
  100. package/dashboard/dist/clawd/react/left.svg +102 -0
  101. package/dashboard/dist/clawd/react/right.svg +102 -0
  102. package/dashboard/dist/clawd/sleep/collapse-sleep.svg +247 -0
  103. package/dashboard/dist/clawd/sleep/sleeping.svg +118 -0
  104. package/dashboard/dist/clawd/sleep/wake.svg +277 -0
  105. package/dashboard/dist/clawd/static-base.svg +21 -0
  106. package/dashboard/dist/clawd/status/disconnected.svg +136 -0
  107. package/dashboard/dist/clawd/status/error.svg +94 -0
  108. package/dashboard/dist/clawd/status/notification.svg +134 -0
  109. package/dashboard/dist/clawd/working/building.svg +231 -0
  110. package/dashboard/dist/clawd/working/carrying.svg +107 -0
  111. package/dashboard/dist/clawd/working/conducting.svg +118 -0
  112. package/dashboard/dist/clawd/working/confused.svg +113 -0
  113. package/dashboard/dist/clawd/working/debugger.svg +91 -0
  114. package/dashboard/dist/clawd/working/juggling.svg +82 -0
  115. package/dashboard/dist/clawd/working/overheated.svg +75 -0
  116. package/dashboard/dist/clawd/working/pushing.svg +125 -0
  117. package/dashboard/dist/clawd/working/sweeping.svg +79 -0
  118. package/dashboard/dist/clawd/working/thinking.svg +119 -0
  119. package/dashboard/dist/clawd/working/typing.svg +153 -0
  120. package/dashboard/dist/clawd/working/ultrathink.svg +166 -0
  121. package/dashboard/dist/clawd/working/wizard.svg +98 -0
  122. package/dashboard/dist/dashboard-dark.png +0 -0
  123. package/dashboard/dist/favicon-16.png +0 -0
  124. package/dashboard/dist/favicon-32.png +0 -0
  125. package/dashboard/dist/favicon.ico +0 -0
  126. package/dashboard/dist/feed.xml +34 -0
  127. package/dashboard/dist/icon-192.png +0 -0
  128. package/dashboard/dist/icon-512.png +0 -0
  129. package/dashboard/dist/index.html +355 -0
  130. package/dashboard/dist/llms.txt +53 -0
  131. package/dashboard/dist/neon_bg.png +0 -0
  132. package/dashboard/dist/robots.txt +7 -0
  133. package/dashboard/dist/share.html +140 -0
  134. package/dashboard/dist/sitemap.xml +27 -0
  135. package/dashboard/dist/widgets-overview.png +0 -0
  136. package/package.json +78 -0
  137. package/scripts/install-local-service.sh +140 -0
  138. package/scripts/uninstall-local-service.sh +21 -0
  139. package/src/cli.js +95 -0
  140. package/src/commands/device-login.js +161 -0
  141. package/src/commands/diagnostics.js +38 -0
  142. package/src/commands/doctor.js +96 -0
  143. package/src/commands/init.js +1195 -0
  144. package/src/commands/serve.js +351 -0
  145. package/src/commands/status.js +609 -0
  146. package/src/commands/sync.js +2285 -0
  147. package/src/commands/uninstall.js +189 -0
  148. package/src/commands/wrapped.js +150 -0
  149. package/src/lib/antigravity-paths.js +48 -0
  150. package/src/lib/browser-auth.js +281 -0
  151. package/src/lib/categorizer-utils.js +232 -0
  152. package/src/lib/claude-categorizer.js +1319 -0
  153. package/src/lib/claude-config.js +204 -0
  154. package/src/lib/cli-ui.js +179 -0
  155. package/src/lib/codex-config.js +350 -0
  156. package/src/lib/codex-context-breakdown.js +400 -0
  157. package/src/lib/codex-rollout-parser.js +623 -0
  158. package/src/lib/codex-token-refresh.js +112 -0
  159. package/src/lib/cursor-config.js +425 -0
  160. package/src/lib/debug-flags.js +7 -0
  161. package/src/lib/diagnostics.js +272 -0
  162. package/src/lib/doctor.js +338 -0
  163. package/src/lib/fs.js +91 -0
  164. package/src/lib/gemini-config.js +288 -0
  165. package/src/lib/grok-hook.js +351 -0
  166. package/src/lib/init-flow.js +42 -0
  167. package/src/lib/local-api.js +1861 -0
  168. package/src/lib/openclaw-hook.js +420 -0
  169. package/src/lib/openclaw-session-plugin.js +520 -0
  170. package/src/lib/opencode-config.js +99 -0
  171. package/src/lib/passive-mode.js +185 -0
  172. package/src/lib/pricing/curated-overrides.json +79 -0
  173. package/src/lib/pricing/index.js +143 -0
  174. package/src/lib/pricing/litellm-fetcher.js +172 -0
  175. package/src/lib/pricing/matcher.js +255 -0
  176. package/src/lib/pricing/seed-snapshot.json +1 -0
  177. package/src/lib/progress.js +76 -0
  178. package/src/lib/project-usage-purge.js +106 -0
  179. package/src/lib/prompt.js +24 -0
  180. package/src/lib/proxy-env.js +131 -0
  181. package/src/lib/rollout.js +8442 -0
  182. package/src/lib/runtime-config.js +122 -0
  183. package/src/lib/skill-usage.js +267 -0
  184. package/src/lib/skills-manager.js +1127 -0
  185. package/src/lib/source-metadata.js +48 -0
  186. package/src/lib/sqlite-reader.js +136 -0
  187. package/src/lib/static-server.js +59 -0
  188. package/src/lib/subscriptions.js +453 -0
  189. package/src/lib/tracker-paths.js +16 -0
  190. package/src/lib/upload-throttle.js +148 -0
  191. package/src/lib/usage-limits.js +1806 -0
  192. package/src/lib/wrapped-aggregator.js +225 -0
@@ -0,0 +1,48 @@
1
+ const ACCOUNT_LEVEL_SOURCES = new Set(["cursor"]);
2
+
3
+ function normalizeSource(value) {
4
+ return String(value || "").trim().toLowerCase();
5
+ }
6
+
7
+ function getSourceScope(source) {
8
+ return ACCOUNT_LEVEL_SOURCES.has(normalizeSource(source)) ? "account" : "local";
9
+ }
10
+
11
+ function isAccountLevelSource(source) {
12
+ return getSourceScope(source) === "account";
13
+ }
14
+
15
+ function normalizeUsageScope(value) {
16
+ const raw = String(value || "").trim().toLowerCase();
17
+ if (!raw || raw === "all" || raw === "raw") return "all";
18
+ if (raw === "personal" || raw === "local") return "personal";
19
+ return "all";
20
+ }
21
+
22
+ function filterRowsByUsageScope(rows, scope = "all") {
23
+ const normalizedScope = normalizeUsageScope(scope);
24
+ if (normalizedScope === "all") return Array.isArray(rows) ? rows : [];
25
+ return (Array.isArray(rows) ? rows : []).filter((row) => !isAccountLevelSource(row?.source));
26
+ }
27
+
28
+ function listExcludedSources(rows, scope = "all") {
29
+ const normalizedScope = normalizeUsageScope(scope);
30
+ if (normalizedScope === "all") return [];
31
+ const seen = new Set();
32
+ const out = [];
33
+ for (const row of Array.isArray(rows) ? rows : []) {
34
+ const source = normalizeSource(row?.source);
35
+ if (!source || seen.has(source) || !isAccountLevelSource(source)) continue;
36
+ seen.add(source);
37
+ out.push({ source, source_scope: getSourceScope(source), reason: "account_level_source" });
38
+ }
39
+ return out.sort((a, b) => a.source.localeCompare(b.source));
40
+ }
41
+
42
+ module.exports = {
43
+ getSourceScope,
44
+ isAccountLevelSource,
45
+ normalizeUsageScope,
46
+ filterRowsByUsageScope,
47
+ listExcludedSources,
48
+ };
@@ -0,0 +1,136 @@
1
+ const cp = require("node:child_process");
2
+ const fssync = require("node:fs");
3
+
4
+ const warnedSqliteReadFailures = new Set();
5
+
6
+ function isDebugEnabled(env = process.env) {
7
+ const value = String((env && env.TOKENTRACKER_DEBUG) || "").toLowerCase();
8
+ return value === "1" || value === "true";
9
+ }
10
+
11
+ function formatError(err) {
12
+ if (!err) return "unknown error";
13
+ return err && err.message ? err.message : String(err);
14
+ }
15
+
16
+ function warnSqliteUnavailable({ dbPath, label, cliError, nodeSqliteError, env, stderr }) {
17
+ const key = `${label || "SQLite"}:${dbPath || ""}`;
18
+ if (warnedSqliteReadFailures.has(key)) return;
19
+ warnedSqliteReadFailures.add(key);
20
+
21
+ const out = stderr && typeof stderr.write === "function" ? stderr : process.stderr;
22
+ const displayLabel = label || "local";
23
+ out.write(
24
+ `[tokentracker] Cannot read ${displayLabel} SQLite database. Install sqlite3 CLI and add it to PATH, or use Node.js 22+ with node:sqlite support. Path: ${dbPath}\n`,
25
+ );
26
+ if (isDebugEnabled(env)) {
27
+ out.write(`[tokentracker] sqlite3 CLI failed: ${formatError(cliError)}\n`);
28
+ out.write(`[tokentracker] node:sqlite failed: ${formatError(nodeSqliteError)}\n`);
29
+ }
30
+ }
31
+
32
+ function readSqliteRowsWithCli(dbPath, sql, { execFileSync, timeout, maxBuffer }) {
33
+ const raw = execFileSync("sqlite3", ["-json", dbPath, sql], {
34
+ encoding: "utf8",
35
+ maxBuffer,
36
+ timeout,
37
+ stdio: ["pipe", "pipe", "pipe"],
38
+ });
39
+ if (!raw || !raw.trim()) return [];
40
+ const rows = JSON.parse(raw);
41
+ return Array.isArray(rows) ? rows : [];
42
+ }
43
+
44
+ function isSqliteCliUnavailable(err) {
45
+ const message = formatError(err).toLowerCase();
46
+ return (
47
+ err?.code === "ENOENT" ||
48
+ message.includes("spawn sqlite3 enoent") ||
49
+ message.includes("sqlite3 enoent") ||
50
+ message.includes("not recognized as an internal or external command")
51
+ );
52
+ }
53
+
54
+ function isNodeSqliteUnavailable(err) {
55
+ const message = formatError(err).toLowerCase();
56
+ return (
57
+ message.includes("no such built-in module") ||
58
+ message.includes("cannot find module 'node:sqlite'") ||
59
+ message.includes('cannot find module "node:sqlite"') ||
60
+ message.includes("node:sqlite databasesync is unavailable")
61
+ );
62
+ }
63
+
64
+ function readSqliteRowsWithNode(dbPath, sql, { requireFn }) {
65
+ const { DatabaseSync } = requireFn("node:sqlite");
66
+ if (typeof DatabaseSync !== "function") {
67
+ throw new Error("node:sqlite DatabaseSync is unavailable");
68
+ }
69
+ const db = new DatabaseSync(dbPath, { readOnly: true });
70
+ try {
71
+ const rows = db.prepare(sql).all();
72
+ return Array.isArray(rows) ? rows : [];
73
+ } finally {
74
+ db.close();
75
+ }
76
+ }
77
+
78
+ function readSqliteJsonRows(dbPath, sql, options = {}) {
79
+ if (!dbPath || !sql) return [];
80
+ try {
81
+ if (!fssync.existsSync(dbPath)) return [];
82
+ } catch (_e) {
83
+ return [];
84
+ }
85
+ const execFileSync = options.execFileSync || cp.execFileSync;
86
+ const requireFn = options.requireFn || require;
87
+ const env = options.env || process.env;
88
+ const timeout = Number.isFinite(options.timeout) ? options.timeout : 30_000;
89
+ const maxBuffer = Number.isFinite(options.maxBuffer) ? options.maxBuffer : 50 * 1024 * 1024;
90
+ const label = options.label || "local";
91
+
92
+ let cliError = null;
93
+ try {
94
+ return readSqliteRowsWithCli(dbPath, sql, { execFileSync, timeout, maxBuffer });
95
+ } catch (err) {
96
+ cliError = err;
97
+ }
98
+
99
+ let nodeSqliteError = null;
100
+ try {
101
+ return readSqliteRowsWithNode(dbPath, sql, { requireFn });
102
+ } catch (err) {
103
+ nodeSqliteError = err;
104
+ }
105
+
106
+ if (isSqliteCliUnavailable(cliError) && isNodeSqliteUnavailable(nodeSqliteError)) {
107
+ warnSqliteUnavailable({
108
+ dbPath,
109
+ label,
110
+ cliError,
111
+ nodeSqliteError,
112
+ env,
113
+ stderr: options.stderr,
114
+ });
115
+ }
116
+ return [];
117
+ }
118
+
119
+ function readSqliteFirstValue(dbPath, sql, column, options = {}) {
120
+ const rows = readSqliteJsonRows(dbPath, sql, options);
121
+ const row = rows[0];
122
+ if (!row || typeof row !== "object") return null;
123
+ const key = typeof column === "string" && column.length > 0 ? column : Object.keys(row)[0];
124
+ const value = row[key];
125
+ return typeof value === "string" ? value.trim() : value == null ? null : String(value).trim();
126
+ }
127
+
128
+ function resetSqliteReaderWarningsForTests() {
129
+ warnedSqliteReadFailures.clear();
130
+ }
131
+
132
+ module.exports = {
133
+ readSqliteJsonRows,
134
+ readSqliteFirstValue,
135
+ resetSqliteReaderWarningsForTests,
136
+ };
@@ -0,0 +1,59 @@
1
+ const fs = require("node:fs");
2
+ const fsPromises = require("node:fs/promises");
3
+ const path = require("node:path");
4
+
5
+ const MIME_TYPES = {
6
+ ".html": "text/html; charset=utf-8",
7
+ ".js": "application/javascript; charset=utf-8",
8
+ ".mjs": "application/javascript; charset=utf-8",
9
+ ".css": "text/css; charset=utf-8",
10
+ ".json": "application/json; charset=utf-8",
11
+ ".png": "image/png",
12
+ ".jpg": "image/jpeg",
13
+ ".jpeg": "image/jpeg",
14
+ ".gif": "image/gif",
15
+ ".svg": "image/svg+xml",
16
+ ".ico": "image/x-icon",
17
+ ".webp": "image/webp",
18
+ ".woff": "font/woff",
19
+ ".woff2": "font/woff2",
20
+ ".ttf": "font/ttf",
21
+ ".xml": "application/xml",
22
+ ".txt": "text/plain; charset=utf-8",
23
+ ".webmanifest": "application/manifest+json",
24
+ };
25
+
26
+ /**
27
+ * Serve a static file from baseDir. Returns true if served, false otherwise.
28
+ * For SPA: caller should fall back to index.html when this returns false.
29
+ */
30
+ async function serveStaticFile(baseDir, pathname, res) {
31
+ const safePath = path.normalize(pathname).replace(/^(\.\.[/\\])+/, "");
32
+ const filePath = path.join(baseDir, safePath);
33
+
34
+ // prevent directory traversal
35
+ if (!filePath.startsWith(baseDir)) return false;
36
+
37
+ try {
38
+ const stat = await fsPromises.stat(filePath);
39
+ if (!stat.isFile()) return false;
40
+
41
+ const ext = path.extname(filePath).toLowerCase();
42
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
43
+ const isHtml = ext === ".html";
44
+
45
+ res.writeHead(200, {
46
+ "Content-Type": contentType,
47
+ "Content-Length": stat.size,
48
+ "Cache-Control": isHtml ? "no-cache" : "public, max-age=31536000, immutable",
49
+ });
50
+
51
+ const stream = fs.createReadStream(filePath);
52
+ stream.pipe(res);
53
+ return true;
54
+ } catch (_e) {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ module.exports = { serveStaticFile };
@@ -0,0 +1,453 @@
1
+ const os = require("node:os");
2
+ const path = require("node:path");
3
+ const fs = require("node:fs");
4
+ const cp = require("node:child_process");
5
+
6
+ const { readJson } = require("./fs");
7
+ const { resolveTrackerPaths } = require("./tracker-paths");
8
+ const { probeOpenclawSessionPluginState } = require("./openclaw-session-plugin");
9
+
10
+ const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
11
+ const MACOS_SECURITY_BIN = "/usr/bin/security";
12
+ const CLAUDE_CODE_KEYCHAIN_SERVICES = ["Claude Code-credentials"];
13
+ // On Linux and Windows, Claude Code persists the same OAuth payload as a plain
14
+ // JSON file instead of the macOS Keychain — at ~/.claude/.credentials.json on
15
+ // Linux and %USERPROFILE%\.claude\.credentials.json on Windows (both resolve via
16
+ // os.homedir()). The payload shape is identical:
17
+ // { claudeAiOauth: { accessToken, subscriptionType, ... } }
18
+ const CLAUDE_CODE_CREDENTIALS_FILE = ".credentials.json";
19
+ // Platforms where Claude Code stores credentials in the plain JSON file above
20
+ // rather than the macOS Keychain.
21
+ const CLAUDE_CODE_CREDENTIALS_FILE_PLATFORMS = new Set(["linux", "win32"]);
22
+
23
+ function usesClaudeCodeCredentialsFile(platform) {
24
+ return CLAUDE_CODE_CREDENTIALS_FILE_PLATFORMS.has(platform);
25
+ }
26
+
27
+ function normalizeString(value) {
28
+ if (typeof value !== "string") return null;
29
+ const trimmed = value.trim();
30
+ return trimmed.length > 0 ? trimmed : null;
31
+ }
32
+
33
+ function normalizeScalarToString(value) {
34
+ if (value === null || value === undefined) return null;
35
+ if (typeof value === "string") return normalizeString(value);
36
+ if (typeof value === "number") {
37
+ if (!Number.isFinite(value)) return null;
38
+ return normalizeString(String(value));
39
+ }
40
+ if (typeof value === "boolean") return value ? "true" : "false";
41
+ return null;
42
+ }
43
+
44
+ function base64UrlDecodeToString(value) {
45
+ if (typeof value !== "string" || value.length === 0) return null;
46
+ const padLen = (4 - (value.length % 4)) % 4;
47
+ const padded = value + "=".repeat(padLen);
48
+ const base64 = padded.replace(/-/g, "+").replace(/_/g, "/");
49
+ try {
50
+ return Buffer.from(base64, "base64").toString("utf8");
51
+ } catch (_e) {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function decodeJwtPayload(token) {
57
+ const jwt = normalizeString(token);
58
+ if (!jwt) return null;
59
+ const parts = jwt.split(".");
60
+ if (parts.length < 2) return null;
61
+ const decoded = base64UrlDecodeToString(parts[1]);
62
+ if (!decoded) return null;
63
+ try {
64
+ return JSON.parse(decoded);
65
+ } catch (_e) {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function extractOpenAiAuthNamespace(payload) {
71
+ if (!payload || typeof payload !== "object") return null;
72
+ const ns = payload[OPENAI_AUTH_CLAIM];
73
+ if (!ns || typeof ns !== "object" || Array.isArray(ns)) return null;
74
+ return ns;
75
+ }
76
+
77
+ function extractChatgptSubscriptionFromPayload(payload) {
78
+ const ns = extractOpenAiAuthNamespace(payload);
79
+ if (!ns) return null;
80
+
81
+ const planType = normalizeString(ns.chatgpt_plan_type);
82
+ const activeStart = normalizeString(ns.chatgpt_subscription_active_start);
83
+ const activeUntil = normalizeString(ns.chatgpt_subscription_active_until);
84
+ const lastChecked = normalizeString(ns.chatgpt_subscription_last_checked);
85
+
86
+ if (!planType && !activeStart && !activeUntil && !lastChecked) return null;
87
+ return { planType, activeStart, activeUntil, lastChecked };
88
+ }
89
+
90
+ function mergeSubscription(primary, secondary) {
91
+ if (!primary && !secondary) return null;
92
+ const a = primary || {};
93
+ const b = secondary || {};
94
+ return {
95
+ planType: a.planType || b.planType || null,
96
+ activeStart: a.activeStart || b.activeStart || null,
97
+ activeUntil: a.activeUntil || b.activeUntil || null,
98
+ lastChecked: a.lastChecked || b.lastChecked || null,
99
+ };
100
+ }
101
+
102
+ function isDisplayablePlanType(planType) {
103
+ const normalized = normalizeString(planType);
104
+ if (!normalized) return false;
105
+ const v = normalized.toLowerCase();
106
+ if (v === "free" || v === "none" || v === "unknown") return false;
107
+ return true;
108
+ }
109
+
110
+ function resolveCodexHome({ home, env }) {
111
+ const explicit = normalizeString(env?.CODEX_HOME);
112
+ return explicit ? path.resolve(explicit) : path.join(home, ".codex");
113
+ }
114
+
115
+ function resolveOpencodeDataDir({ home, env }) {
116
+ const explicit = normalizeString(env?.XDG_DATA_HOME);
117
+ const base = explicit ? path.resolve(explicit) : path.join(home, ".local", "share");
118
+ return path.join(base, "opencode");
119
+ }
120
+
121
+ async function detectCodexChatgptSubscription({ home, env }) {
122
+ const codexHome = resolveCodexHome({ home, env });
123
+ const authPath = path.join(codexHome, "auth.json");
124
+ const auth = await readJson(authPath);
125
+ if (!auth || typeof auth !== "object") return null;
126
+
127
+ const accessPayload = decodeJwtPayload(auth?.tokens?.access_token);
128
+ const idPayload = decodeJwtPayload(auth?.tokens?.id_token);
129
+
130
+ const accessInfo = extractChatgptSubscriptionFromPayload(accessPayload);
131
+ const idInfo = extractChatgptSubscriptionFromPayload(idPayload);
132
+ const merged = mergeSubscription(accessInfo, idInfo);
133
+ if (!merged || !isDisplayablePlanType(merged.planType)) return null;
134
+
135
+ return {
136
+ tool: "codex",
137
+ provider: "openai",
138
+ product: "chatgpt",
139
+ planType: merged.planType,
140
+ activeStart: merged.activeStart,
141
+ activeUntil: merged.activeUntil,
142
+ lastChecked: merged.lastChecked,
143
+ };
144
+ }
145
+
146
+ async function detectOpencodeChatgptSubscription({ home, env }) {
147
+ const dataDir = resolveOpencodeDataDir({ home, env });
148
+ const authPath = path.join(dataDir, "auth.json");
149
+ const auth = await readJson(authPath);
150
+ if (!auth || typeof auth !== "object") return null;
151
+
152
+ const accessPayload = decodeJwtPayload(auth?.openai?.access);
153
+ const info = extractChatgptSubscriptionFromPayload(accessPayload);
154
+ if (!info || !isDisplayablePlanType(info.planType)) return null;
155
+
156
+ return {
157
+ tool: "opencode",
158
+ provider: "openai",
159
+ product: "chatgpt",
160
+ planType: info.planType,
161
+ activeStart: info.activeStart,
162
+ activeUntil: info.activeUntil,
163
+ lastChecked: info.lastChecked,
164
+ };
165
+ }
166
+
167
+ function probeMacosKeychainGenericPassword({ service, securityRunner, timeoutMs } = {}) {
168
+ const svc = normalizeString(service);
169
+ if (!svc) return false;
170
+
171
+ const runner = typeof securityRunner === "function" ? securityRunner : cp.spawnSync;
172
+ if (runner === cp.spawnSync && !fs.existsSync(MACOS_SECURITY_BIN)) return false;
173
+
174
+ const result = runner(MACOS_SECURITY_BIN, ["find-generic-password", "-s", svc], {
175
+ stdio: "ignore",
176
+ timeout: Number.isFinite(timeoutMs) ? timeoutMs : 2000,
177
+ });
178
+
179
+ if (!result || result.error) return false;
180
+ return result.status === 0;
181
+ }
182
+
183
+ function readMacosKeychainPassword({ service, securityRunner, timeoutMs } = {}) {
184
+ const svc = normalizeString(service);
185
+ if (!svc) return null;
186
+
187
+ const runner = typeof securityRunner === "function" ? securityRunner : cp.spawnSync;
188
+ if (runner === cp.spawnSync && !fs.existsSync(MACOS_SECURITY_BIN)) return null;
189
+
190
+ const result = runner(MACOS_SECURITY_BIN, ["find-generic-password", "-s", svc, "-w"], {
191
+ stdio: ["ignore", "pipe", "ignore"],
192
+ timeout: Number.isFinite(timeoutMs) ? timeoutMs : 2000,
193
+ encoding: "utf8",
194
+ });
195
+
196
+ if (!result || result.error) return null;
197
+ if (result.status !== 0) return null;
198
+
199
+ const stdout =
200
+ typeof result.stdout === "string"
201
+ ? result.stdout
202
+ : Buffer.isBuffer(result.stdout)
203
+ ? result.stdout.toString("utf8")
204
+ : "";
205
+ const trimmed = stdout.trim();
206
+ return trimmed.length > 0 ? trimmed : null;
207
+ }
208
+
209
+ function readClaudeCodeCredentialsFile({ home, fsReader } = {}) {
210
+ const homeDir = typeof home === "string" && home ? home : os.homedir();
211
+ const credPath = path.join(homeDir, ".claude", CLAUDE_CODE_CREDENTIALS_FILE);
212
+ const reader = typeof fsReader === "function" ? fsReader : fs.readFileSync;
213
+ try {
214
+ return reader(credPath, "utf8");
215
+ } catch (_e) {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ function detectClaudeCodeCredentialsPresence({ platform, securityRunner, home, fsReader } = {}) {
221
+ if (platform === "darwin") {
222
+ for (const service of CLAUDE_CODE_KEYCHAIN_SERVICES) {
223
+ const present = probeMacosKeychainGenericPassword({
224
+ service,
225
+ securityRunner,
226
+ });
227
+ if (!present) continue;
228
+
229
+ // Existence-only probe: do not read secrets or infer paid tier.
230
+ return {
231
+ tool: "claude",
232
+ provider: "anthropic",
233
+ product: "credentials",
234
+ planType: "present",
235
+ };
236
+ }
237
+ return null;
238
+ }
239
+
240
+ if (!usesClaudeCodeCredentialsFile(platform)) return null;
241
+
242
+ // Linux/Windows: credentials live in the .credentials.json file (mode 0600 on
243
+ // Linux; user-profile ACLs on Windows). Existence-only: just check that the
244
+ // file is readable and contains the OAuth key.
245
+ const raw = readClaudeCodeCredentialsFile({ home, fsReader });
246
+ if (!raw) return null;
247
+ try {
248
+ const payload = JSON.parse(raw);
249
+ if (payload?.claudeAiOauth && typeof payload.claudeAiOauth === "object") {
250
+ return {
251
+ tool: "claude",
252
+ provider: "anthropic",
253
+ product: "credentials",
254
+ planType: "present",
255
+ };
256
+ }
257
+ } catch (_e) {
258
+ // fall through
259
+ }
260
+ return null;
261
+ }
262
+
263
+ function extractClaudeKeychainSubscription(payload) {
264
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
265
+
266
+ const oauth = payload.claudeAiOauth;
267
+ if (!oauth || typeof oauth !== "object" || Array.isArray(oauth)) return null;
268
+
269
+ const subscriptionType = normalizeScalarToString(oauth.subscriptionType);
270
+ const rateLimitTier = normalizeScalarToString(oauth.rateLimitTier);
271
+
272
+ if (!subscriptionType) return null;
273
+ return { subscriptionType, rateLimitTier };
274
+ }
275
+
276
+ function detectClaudeCodeSubscriptionDetails({ platform, securityRunner, home, fsReader } = {}) {
277
+ const rawPayloads = [];
278
+ if (platform === "darwin") {
279
+ for (const service of CLAUDE_CODE_KEYCHAIN_SERVICES) {
280
+ const raw = readMacosKeychainPassword({ service, securityRunner });
281
+ if (raw) rawPayloads.push(raw);
282
+ }
283
+ } else if (usesClaudeCodeCredentialsFile(platform)) {
284
+ const raw = readClaudeCodeCredentialsFile({ home, fsReader });
285
+ if (raw) rawPayloads.push(raw);
286
+ } else {
287
+ return null;
288
+ }
289
+
290
+ for (const raw of rawPayloads) {
291
+ let payload;
292
+ try {
293
+ payload = JSON.parse(raw);
294
+ } catch (_e) {
295
+ continue;
296
+ }
297
+
298
+ const info = extractClaudeKeychainSubscription(payload);
299
+ if (!info) continue;
300
+
301
+ return {
302
+ tool: "claude",
303
+ provider: "anthropic",
304
+ product: "subscription",
305
+ planType: info.subscriptionType,
306
+ rateLimitTier: info.rateLimitTier,
307
+ };
308
+ }
309
+
310
+ return null;
311
+ }
312
+
313
+ async function collectLocalSubscriptions({
314
+ home = os.homedir(),
315
+ env = process.env,
316
+ platform = process.platform,
317
+ securityRunner,
318
+ probeKeychain = false,
319
+ probeKeychainDetails = false,
320
+ } = {}) {
321
+ const out = [];
322
+
323
+ const codex = await detectCodexChatgptSubscription({ home, env });
324
+ if (codex) out.push(codex);
325
+
326
+ const opencode = await detectOpencodeChatgptSubscription({ home, env });
327
+ if (opencode) out.push(opencode);
328
+
329
+ if (probeKeychainDetails) {
330
+ const claude = detectClaudeCodeSubscriptionDetails({ platform, securityRunner, home });
331
+ if (claude) out.push(claude);
332
+ else if (probeKeychain) {
333
+ const present = detectClaudeCodeCredentialsPresence({ platform, securityRunner, home });
334
+ if (present) out.push(present);
335
+ }
336
+ } else if (probeKeychain) {
337
+ const claude = detectClaudeCodeCredentialsPresence({ platform, securityRunner, home });
338
+ if (claude) out.push(claude);
339
+ }
340
+
341
+ const openclaw = await detectOpenclawSessionIntegration({ home, env });
342
+ if (openclaw) out.push(openclaw);
343
+
344
+ // Gemini: no stable local subscription/tier signal found yet.
345
+ return out;
346
+ }
347
+
348
+ async function detectOpenclawSessionIntegration({ home, env }) {
349
+ const { trackerDir } = await resolveTrackerPaths({ home });
350
+ let state = null;
351
+ try {
352
+ state = await probeOpenclawSessionPluginState({ home, trackerDir, env });
353
+ } catch (_err) {
354
+ return null;
355
+ }
356
+
357
+ if (!state?.configured) return null;
358
+
359
+ return {
360
+ tool: "openclaw",
361
+ provider: "openclaw",
362
+ product: "session_plugin",
363
+ planType: "enabled",
364
+ };
365
+ }
366
+
367
+ function readClaudeCodeAccessToken({ platform, securityRunner, home, fsReader } = {}) {
368
+ if (platform === "darwin") {
369
+ for (const service of CLAUDE_CODE_KEYCHAIN_SERVICES) {
370
+ try {
371
+ const raw = readMacosKeychainPassword({ service, securityRunner });
372
+ if (!raw) continue;
373
+ const payload = JSON.parse(raw);
374
+ return normalizeString(payload?.claudeAiOauth?.accessToken);
375
+ } catch (_e) {
376
+ continue;
377
+ }
378
+ }
379
+ return null;
380
+ }
381
+
382
+ if (!usesClaudeCodeCredentialsFile(platform)) return null;
383
+
384
+ // Linux/Windows: Claude Code stores the OAuth payload as a JSON file
385
+ // (mode 0600 on Linux; user-profile ACLs on Windows).
386
+ const raw = readClaudeCodeCredentialsFile({ home, fsReader });
387
+ if (!raw) return null;
388
+ try {
389
+ const payload = JSON.parse(raw);
390
+ return normalizeString(payload?.claudeAiOauth?.accessToken);
391
+ } catch (_e) {
392
+ return null;
393
+ }
394
+ }
395
+
396
+ async function readCodexAccessToken({ home, env } = {}) {
397
+ try {
398
+ const codexHome = resolveCodexHome({ home, env });
399
+ const authPath = path.join(codexHome, "auth.json");
400
+ const auth = await readJson(authPath);
401
+ return normalizeString(auth?.tokens?.access_token);
402
+ } catch (_e) {
403
+ return null;
404
+ }
405
+ }
406
+
407
+ // Returns the access token + ChatGPT account id + plan type + auth.json path so callers can
408
+ // also trigger a refresh if the tokens are stale (issue #52: stale tokens → wham 401 →
409
+ // "Fetch failed" error after ~7-8 days of not running `codex`).
410
+ async function readCodexAuthBundle({ home, env } = {}) {
411
+ try {
412
+ const codexHome = resolveCodexHome({ home, env });
413
+ const authPath = path.join(codexHome, "auth.json");
414
+ const auth = await readJson(authPath);
415
+ if (!auth || typeof auth !== "object") return null;
416
+
417
+ const accessToken = normalizeString(auth?.tokens?.access_token);
418
+ if (!accessToken) return null;
419
+
420
+ const accessPayload = decodeJwtPayload(auth?.tokens?.access_token);
421
+ const idPayload = decodeJwtPayload(auth?.tokens?.id_token);
422
+ const accountId =
423
+ normalizeString(auth?.tokens?.account_id) ||
424
+ normalizeString(extractOpenAiAuthNamespace(accessPayload)?.chatgpt_account_id) ||
425
+ normalizeString(extractOpenAiAuthNamespace(idPayload)?.chatgpt_account_id) ||
426
+ null;
427
+
428
+ const accessInfo = extractChatgptSubscriptionFromPayload(accessPayload);
429
+ const idInfo = extractChatgptSubscriptionFromPayload(idPayload);
430
+ const merged = mergeSubscription(accessInfo, idInfo);
431
+ const planType = merged?.planType ? merged.planType.toLowerCase() : null;
432
+
433
+ return {
434
+ accessToken,
435
+ accountId,
436
+ planType,
437
+ refreshToken: normalizeString(auth?.tokens?.refresh_token) || null,
438
+ lastRefresh: normalizeString(auth?.last_refresh) || null,
439
+ authPath,
440
+ authJson: auth,
441
+ };
442
+ } catch (_e) {
443
+ return null;
444
+ }
445
+ }
446
+
447
+ module.exports = {
448
+ collectLocalSubscriptions,
449
+ detectClaudeCodeSubscriptionDetails,
450
+ readClaudeCodeAccessToken,
451
+ readCodexAccessToken,
452
+ readCodexAuthBundle,
453
+ };