@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.
- package/LICENSE +21 -0
- package/README.ja.md +464 -0
- package/README.ko.md +464 -0
- package/README.md +476 -0
- package/README.zh-CN.md +469 -0
- package/bin/tracker.js +52 -0
- package/dashboard/dist/app-icon.png +0 -0
- package/dashboard/dist/apple-touch-icon.png +0 -0
- package/dashboard/dist/assets/ActivityHeatmap-BxBGHY1z.js +42 -0
- package/dashboard/dist/assets/Card-GROxUc_6.js +1 -0
- package/dashboard/dist/assets/DashboardPage-BZ5VFoBk.js +19 -0
- package/dashboard/dist/assets/DevicePage-CQts7ivB.js +1 -0
- package/dashboard/dist/assets/DialogTitle-DqFsk26U.js +1 -0
- package/dashboard/dist/assets/FadeIn-HBBXW_3q.js +1 -0
- package/dashboard/dist/assets/HeaderGithubStar-COD2HfaF.js +1 -0
- package/dashboard/dist/assets/IpCheckPage-Cn0l8-BQ.js +15 -0
- package/dashboard/dist/assets/LandingPage-CmV1BMU4.js +4356 -0
- package/dashboard/dist/assets/LeaderboardAvatar-CiIpWZwb.js +1 -0
- package/dashboard/dist/assets/LeaderboardPage-BU0mlR3g.js +6 -0
- package/dashboard/dist/assets/LeaderboardProfileModal-C49_3Xm9.js +72 -0
- package/dashboard/dist/assets/LeaderboardProfilePage-Bh1n0BAM.js +1 -0
- package/dashboard/dist/assets/LimitsPage-fZAmwLWT.js +2 -0
- package/dashboard/dist/assets/LocalOnlyNotice-D3rQdA-P.js +1 -0
- package/dashboard/dist/assets/LoginPage-enO4XP-c.js +1 -0
- package/dashboard/dist/assets/PopoverPopup-pitJEpN1.js +1 -0
- package/dashboard/dist/assets/Select-tT9x4ntD.js +1 -0
- package/dashboard/dist/assets/SelectItemText-C7F7eIm0.js +1 -0
- package/dashboard/dist/assets/SettingsPage-pDasZNU2.js +1 -0
- package/dashboard/dist/assets/SkillsPage-DEu7FY-2.js +1 -0
- package/dashboard/dist/assets/WidgetsPage-DGcO7rbp.js +1 -0
- package/dashboard/dist/assets/WrappedPage-D_1ID213.js +1 -0
- package/dashboard/dist/assets/agent-logos-DXQVg1xy.js +1 -0
- package/dashboard/dist/assets/arrow-up-right-DNLo92Ba.js +1 -0
- package/dashboard/dist/assets/download-DbBBBpJF.js +1 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-BKC3PZkM.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-DdrQ8KBw.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-900-normal-CmoKXrdK.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-900-normal-Cu5MFKsu.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-Br6WNUGb.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-CrmBTzU2.woff2 +0 -0
- package/dashboard/dist/assets/index-BeoRn2gJ.js +2 -0
- package/dashboard/dist/assets/info-Bh5qTz6k.js +1 -0
- package/dashboard/dist/assets/main-BIY3Nj6I.js +969 -0
- package/dashboard/dist/assets/main-G_F9Iu_x.css +1 -0
- package/dashboard/dist/assets/use-limits-display-prefs-B4iJ-bBc.js +1 -0
- package/dashboard/dist/assets/use-native-settings-DHjPFDv8.js +1 -0
- package/dashboard/dist/assets/use-usage-limits-odqe9OY7.js +1 -0
- package/dashboard/dist/assets/useCurrency-BsSZiZMP.js +1 -0
- package/dashboard/dist/assets/useOpenInteractionType-BQhgq4rv.js +12 -0
- package/dashboard/dist/brand-logos/antigravity.svg +1 -0
- package/dashboard/dist/brand-logos/claude-code.svg +1 -0
- package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
- package/dashboard/dist/brand-logos/codex.svg +15 -0
- package/dashboard/dist/brand-logos/copilot.svg +1 -0
- package/dashboard/dist/brand-logos/cursor.svg +1 -0
- package/dashboard/dist/brand-logos/gemini.svg +1 -0
- package/dashboard/dist/brand-logos/grok.svg +1 -0
- package/dashboard/dist/brand-logos/hermes.svg +1 -0
- package/dashboard/dist/brand-logos/kilo.svg +4 -0
- package/dashboard/dist/brand-logos/kimi.svg +1 -0
- package/dashboard/dist/brand-logos/kiro.svg +1 -0
- package/dashboard/dist/brand-logos/openclaw.svg +22 -0
- package/dashboard/dist/brand-logos/opencode.svg +3 -0
- package/dashboard/dist/clawd/happy.svg +161 -0
- package/dashboard/dist/clawd/idle/collapse.svg +101 -0
- package/dashboard/dist/clawd/idle/doze.svg +72 -0
- package/dashboard/dist/clawd/idle/follow.svg +64 -0
- package/dashboard/dist/clawd/idle/living.svg +196 -0
- package/dashboard/dist/clawd/idle/look.svg +115 -0
- package/dashboard/dist/clawd/idle/yawn.svg +158 -0
- package/dashboard/dist/clawd/mini/alert.svg +129 -0
- package/dashboard/dist/clawd/mini/crabwalk.svg +76 -0
- package/dashboard/dist/clawd/mini/enter-sleep.svg +65 -0
- package/dashboard/dist/clawd/mini/enter.svg +115 -0
- package/dashboard/dist/clawd/mini/happy.svg +124 -0
- package/dashboard/dist/clawd/mini/idle-tight.svg +15 -0
- package/dashboard/dist/clawd/mini/idle.svg +83 -0
- package/dashboard/dist/clawd/mini/peek.svg +82 -0
- package/dashboard/dist/clawd/mini/sleep.svg +112 -0
- package/dashboard/dist/clawd/react/double.svg +108 -0
- package/dashboard/dist/clawd/react/drag.svg +102 -0
- package/dashboard/dist/clawd/react/left.svg +102 -0
- package/dashboard/dist/clawd/react/right.svg +102 -0
- package/dashboard/dist/clawd/sleep/collapse-sleep.svg +247 -0
- package/dashboard/dist/clawd/sleep/sleeping.svg +118 -0
- package/dashboard/dist/clawd/sleep/wake.svg +277 -0
- package/dashboard/dist/clawd/static-base.svg +21 -0
- package/dashboard/dist/clawd/status/disconnected.svg +136 -0
- package/dashboard/dist/clawd/status/error.svg +94 -0
- package/dashboard/dist/clawd/status/notification.svg +134 -0
- package/dashboard/dist/clawd/working/building.svg +231 -0
- package/dashboard/dist/clawd/working/carrying.svg +107 -0
- package/dashboard/dist/clawd/working/conducting.svg +118 -0
- package/dashboard/dist/clawd/working/confused.svg +113 -0
- package/dashboard/dist/clawd/working/debugger.svg +91 -0
- package/dashboard/dist/clawd/working/juggling.svg +82 -0
- package/dashboard/dist/clawd/working/overheated.svg +75 -0
- package/dashboard/dist/clawd/working/pushing.svg +125 -0
- package/dashboard/dist/clawd/working/sweeping.svg +79 -0
- package/dashboard/dist/clawd/working/thinking.svg +119 -0
- package/dashboard/dist/clawd/working/typing.svg +153 -0
- package/dashboard/dist/clawd/working/ultrathink.svg +166 -0
- package/dashboard/dist/clawd/working/wizard.svg +98 -0
- package/dashboard/dist/dashboard-dark.png +0 -0
- package/dashboard/dist/favicon-16.png +0 -0
- package/dashboard/dist/favicon-32.png +0 -0
- package/dashboard/dist/favicon.ico +0 -0
- package/dashboard/dist/feed.xml +34 -0
- package/dashboard/dist/icon-192.png +0 -0
- package/dashboard/dist/icon-512.png +0 -0
- package/dashboard/dist/index.html +355 -0
- package/dashboard/dist/llms.txt +53 -0
- package/dashboard/dist/neon_bg.png +0 -0
- package/dashboard/dist/robots.txt +7 -0
- package/dashboard/dist/share.html +140 -0
- package/dashboard/dist/sitemap.xml +27 -0
- package/dashboard/dist/widgets-overview.png +0 -0
- package/package.json +78 -0
- package/scripts/install-local-service.sh +140 -0
- package/scripts/uninstall-local-service.sh +21 -0
- package/src/cli.js +95 -0
- package/src/commands/device-login.js +161 -0
- package/src/commands/diagnostics.js +38 -0
- package/src/commands/doctor.js +96 -0
- package/src/commands/init.js +1195 -0
- package/src/commands/serve.js +351 -0
- package/src/commands/status.js +609 -0
- package/src/commands/sync.js +2285 -0
- package/src/commands/uninstall.js +189 -0
- package/src/commands/wrapped.js +150 -0
- package/src/lib/antigravity-paths.js +48 -0
- package/src/lib/browser-auth.js +281 -0
- package/src/lib/categorizer-utils.js +232 -0
- package/src/lib/claude-categorizer.js +1319 -0
- package/src/lib/claude-config.js +204 -0
- package/src/lib/cli-ui.js +179 -0
- package/src/lib/codex-config.js +350 -0
- package/src/lib/codex-context-breakdown.js +400 -0
- package/src/lib/codex-rollout-parser.js +623 -0
- package/src/lib/codex-token-refresh.js +112 -0
- package/src/lib/cursor-config.js +425 -0
- package/src/lib/debug-flags.js +7 -0
- package/src/lib/diagnostics.js +272 -0
- package/src/lib/doctor.js +338 -0
- package/src/lib/fs.js +91 -0
- package/src/lib/gemini-config.js +288 -0
- package/src/lib/grok-hook.js +351 -0
- package/src/lib/init-flow.js +42 -0
- package/src/lib/local-api.js +1861 -0
- package/src/lib/openclaw-hook.js +420 -0
- package/src/lib/openclaw-session-plugin.js +520 -0
- package/src/lib/opencode-config.js +99 -0
- package/src/lib/passive-mode.js +185 -0
- package/src/lib/pricing/curated-overrides.json +79 -0
- package/src/lib/pricing/index.js +143 -0
- package/src/lib/pricing/litellm-fetcher.js +172 -0
- package/src/lib/pricing/matcher.js +255 -0
- package/src/lib/pricing/seed-snapshot.json +1 -0
- package/src/lib/progress.js +76 -0
- package/src/lib/project-usage-purge.js +106 -0
- package/src/lib/prompt.js +24 -0
- package/src/lib/proxy-env.js +131 -0
- package/src/lib/rollout.js +8442 -0
- package/src/lib/runtime-config.js +122 -0
- package/src/lib/skill-usage.js +267 -0
- package/src/lib/skills-manager.js +1127 -0
- package/src/lib/source-metadata.js +48 -0
- package/src/lib/sqlite-reader.js +136 -0
- package/src/lib/static-server.js +59 -0
- package/src/lib/subscriptions.js +453 -0
- package/src/lib/tracker-paths.js +16 -0
- package/src/lib/upload-throttle.js +148 -0
- package/src/lib/usage-limits.js +1806 -0
- package/src/lib/wrapped-aggregator.js +225 -0
|
@@ -0,0 +1,1195 @@
|
|
|
1
|
+
const os = require("node:os");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const fs = require("node:fs/promises");
|
|
4
|
+
const fssync = require("node:fs");
|
|
5
|
+
const cp = require("node:child_process");
|
|
6
|
+
const crypto = require("node:crypto");
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
ensureDir,
|
|
10
|
+
writeFileAtomic,
|
|
11
|
+
readJson,
|
|
12
|
+
writeJson,
|
|
13
|
+
chmod600IfPossible,
|
|
14
|
+
} = require("../lib/fs");
|
|
15
|
+
const { prompt, promptHidden } = require("../lib/prompt");
|
|
16
|
+
const {
|
|
17
|
+
upsertCodexNotify,
|
|
18
|
+
upsertEveryCodeNotify,
|
|
19
|
+
readCodexNotify,
|
|
20
|
+
readEveryCodeNotify,
|
|
21
|
+
} = require("../lib/codex-config");
|
|
22
|
+
const {
|
|
23
|
+
upsertClaudeHook,
|
|
24
|
+
buildClaudeHookCommand,
|
|
25
|
+
buildHookCommand,
|
|
26
|
+
isClaudeHookConfigured,
|
|
27
|
+
} = require("../lib/claude-config");
|
|
28
|
+
const {
|
|
29
|
+
resolveGeminiConfigDir,
|
|
30
|
+
resolveGeminiSettingsPath,
|
|
31
|
+
buildGeminiHookCommand,
|
|
32
|
+
upsertGeminiHook,
|
|
33
|
+
isGeminiHookConfigured,
|
|
34
|
+
} = require("../lib/gemini-config");
|
|
35
|
+
const {
|
|
36
|
+
resolveOpencodeConfigDir,
|
|
37
|
+
upsertOpencodePlugin,
|
|
38
|
+
isOpencodePluginInstalled,
|
|
39
|
+
} = require("../lib/opencode-config");
|
|
40
|
+
const { isCursorInstalled, extractCursorSessionToken } = require("../lib/cursor-config");
|
|
41
|
+
const { removeOpenclawHookConfig, probeOpenclawHookState } = require("../lib/openclaw-hook");
|
|
42
|
+
const {
|
|
43
|
+
installOpenclawSessionPlugin,
|
|
44
|
+
probeOpenclawSessionPluginState,
|
|
45
|
+
} = require("../lib/openclaw-session-plugin");
|
|
46
|
+
const {
|
|
47
|
+
resolveGrokHome,
|
|
48
|
+
resolveGrokHooksDir,
|
|
49
|
+
upsertGrokHook,
|
|
50
|
+
probeGrokHookState,
|
|
51
|
+
removeGrokHook,
|
|
52
|
+
GROK_HOOK_FILENAME
|
|
53
|
+
} = require("../lib/grok-hook");
|
|
54
|
+
const { resolveTrackerPaths } = require("../lib/tracker-paths");
|
|
55
|
+
const {
|
|
56
|
+
resolveOmpAgentDir,
|
|
57
|
+
resolvePiAgentDir,
|
|
58
|
+
piAgentDirCollidesWithOmp,
|
|
59
|
+
} = require("../lib/rollout");
|
|
60
|
+
const { resolveRuntimeConfig, DEFAULT_BASE_URL } = require("../lib/runtime-config");
|
|
61
|
+
const {
|
|
62
|
+
BOLD,
|
|
63
|
+
DIM,
|
|
64
|
+
CYAN,
|
|
65
|
+
RESET,
|
|
66
|
+
color,
|
|
67
|
+
isInteractive,
|
|
68
|
+
promptMenu,
|
|
69
|
+
createSpinner,
|
|
70
|
+
} = require("../lib/cli-ui");
|
|
71
|
+
const { renderLocalReport, renderAuthTransition, renderSuccessBox } = require("../lib/init-flow");
|
|
72
|
+
|
|
73
|
+
const ASCII_LOGO = [
|
|
74
|
+
"████████╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗",
|
|
75
|
+
"╚══██╔══╝██╔═══██╗██║ ██╔╝██╔════╝████╗ ██║",
|
|
76
|
+
" ██║ ██║ ██║█████╔╝ █████╗ ██╔██╗ ██║",
|
|
77
|
+
" ██║ ██║ ██║██╔═██╗ ██╔══╝ ██║╚██╗██║",
|
|
78
|
+
" ██║ ╚██████╔╝██║ ██╗███████╗██║ ╚████║",
|
|
79
|
+
" ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝",
|
|
80
|
+
" ████████╗██████╗ █████╗ ██████╗██╗ ██╗███████╗██████╗",
|
|
81
|
+
" ╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██║ ██╔╝██╔════╝██╔══██╗",
|
|
82
|
+
" ██║ ██████╔╝███████║██║ █████╔╝ █████╗ ██████╔╝",
|
|
83
|
+
" ██║ ██╔══██╗██╔══██║██║ ██╔═██╗ ██╔══╝ ██╔══██╗",
|
|
84
|
+
" ██║ ██║ ██║██║ ██║╚██████╗██║ ██╗███████╗██║ ██║",
|
|
85
|
+
" ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝",
|
|
86
|
+
].join("\n");
|
|
87
|
+
|
|
88
|
+
const DIVIDER = "----------------------------------------------";
|
|
89
|
+
const DEFAULT_DASHBOARD_URL = "https://www.tokentracker.cc";
|
|
90
|
+
|
|
91
|
+
// Single source of truth for the welcome screen's provider count + sample list.
|
|
92
|
+
// Keep in sync with the supported-tools table in CLAUDE.md.
|
|
93
|
+
const SUPPORTED_PROVIDERS = [
|
|
94
|
+
"Claude Code",
|
|
95
|
+
"Codex CLI",
|
|
96
|
+
"Cursor",
|
|
97
|
+
"Gemini CLI",
|
|
98
|
+
"OpenCode",
|
|
99
|
+
"OpenClaw",
|
|
100
|
+
"Every Code",
|
|
101
|
+
"Kiro",
|
|
102
|
+
"Hermes Agent",
|
|
103
|
+
"GitHub Copilot",
|
|
104
|
+
"Kimi Code",
|
|
105
|
+
"oh-my-pi",
|
|
106
|
+
"CodeBuddy",
|
|
107
|
+
"Grok Build",
|
|
108
|
+
"Kilo CLI",
|
|
109
|
+
"Kilo Code",
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
async function cmdInit(argv) {
|
|
113
|
+
const opts = parseArgs(argv);
|
|
114
|
+
const home = os.homedir();
|
|
115
|
+
|
|
116
|
+
const { rootDir, trackerDir, binDir } = await resolveTrackerPaths({ home });
|
|
117
|
+
|
|
118
|
+
const configPath = path.join(trackerDir, "config.json");
|
|
119
|
+
const notifyOriginalPath = path.join(trackerDir, "codex_notify_original.json");
|
|
120
|
+
const linkCodeStatePath = path.join(trackerDir, "link_code_state.json");
|
|
121
|
+
|
|
122
|
+
const existingConfig = await readJson(configPath);
|
|
123
|
+
const runtime = resolveRuntimeConfig({
|
|
124
|
+
cli: { baseUrl: opts.baseUrl, dashboardUrl: opts.dashboardUrl },
|
|
125
|
+
config: existingConfig || {},
|
|
126
|
+
env: process.env,
|
|
127
|
+
});
|
|
128
|
+
const notifyPath = path.join(binDir, "notify.cjs");
|
|
129
|
+
const appDir = path.join(trackerDir, "app");
|
|
130
|
+
const trackerBinPath = path.join(appDir, "bin", "tracker.js");
|
|
131
|
+
|
|
132
|
+
renderWelcome();
|
|
133
|
+
|
|
134
|
+
if (opts.dryRun) {
|
|
135
|
+
process.stdout.write(`${color("Dry run: preview only (no changes applied).", DIM)}\n\n`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (isInteractive() && !opts.yes && !opts.dryRun) {
|
|
139
|
+
const choice = await promptMenu({
|
|
140
|
+
message: "? Proceed with installation?",
|
|
141
|
+
options: ["Yes, configure my environment", "No, exit"],
|
|
142
|
+
defaultIndex: 0,
|
|
143
|
+
});
|
|
144
|
+
const normalizedChoice = String(choice || "")
|
|
145
|
+
.trim()
|
|
146
|
+
.toLowerCase();
|
|
147
|
+
if (normalizedChoice.startsWith("no") || normalizedChoice.includes("exit")) {
|
|
148
|
+
process.stdout.write("Setup cancelled.\n");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (opts.dryRun) {
|
|
154
|
+
const preview = await buildDryRunSummary({
|
|
155
|
+
opts,
|
|
156
|
+
home,
|
|
157
|
+
trackerDir,
|
|
158
|
+
notifyPath,
|
|
159
|
+
runtime,
|
|
160
|
+
});
|
|
161
|
+
renderLocalReport({ summary: preview.summary, isDryRun: true });
|
|
162
|
+
renderAccountNotLinked({ context: "dry-run" });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const spinner = createSpinner({ text: "Analyzing and configuring local environment..." });
|
|
167
|
+
spinner.start();
|
|
168
|
+
let setup;
|
|
169
|
+
try {
|
|
170
|
+
setup = await runSetup({
|
|
171
|
+
opts,
|
|
172
|
+
home,
|
|
173
|
+
trackerDir,
|
|
174
|
+
binDir,
|
|
175
|
+
configPath,
|
|
176
|
+
notifyOriginalPath,
|
|
177
|
+
linkCodeStatePath,
|
|
178
|
+
notifyPath,
|
|
179
|
+
appDir,
|
|
180
|
+
trackerBinPath,
|
|
181
|
+
runtime,
|
|
182
|
+
existingConfig,
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
spinner.stop();
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
spinner.stop();
|
|
189
|
+
|
|
190
|
+
renderLocalReport({ summary: setup.summary, isDryRun: false });
|
|
191
|
+
|
|
192
|
+
// Run first sync inline (with a generous timeout) so we can render the
|
|
193
|
+
// *actual* token total in the success message — the aha moment. If the
|
|
194
|
+
// sync exceeds the timeout we surrender the wait but leave it running, so
|
|
195
|
+
// the dashboard still picks up data shortly after.
|
|
196
|
+
const ahaSpinner = createSpinner({ text: "Running first sync..." });
|
|
197
|
+
ahaSpinner.start();
|
|
198
|
+
let firstSync = null;
|
|
199
|
+
try {
|
|
200
|
+
firstSync = await runFirstSyncAndRead({
|
|
201
|
+
trackerBinPath,
|
|
202
|
+
trackerDir,
|
|
203
|
+
packageName: "tokentracker",
|
|
204
|
+
});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
const msg = err && err.message ? err.message : "unknown error";
|
|
207
|
+
process.stderr.write(`Initial sync issue: ${msg}\n`);
|
|
208
|
+
} finally {
|
|
209
|
+
ahaSpinner.stop();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
renderLocalSuccess({ firstSync });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function renderWelcome() {
|
|
216
|
+
const providerCount = SUPPORTED_PROVIDERS.length;
|
|
217
|
+
// Show first 5 by name for grounding, then "+N more" so the line stays one row.
|
|
218
|
+
const previewNames = SUPPORTED_PROVIDERS.slice(0, 5).join(", ");
|
|
219
|
+
const remaining = providerCount - 5;
|
|
220
|
+
const providerLine =
|
|
221
|
+
remaining > 0
|
|
222
|
+
? `${previewNames} +${remaining} more`
|
|
223
|
+
: previewNames;
|
|
224
|
+
process.stdout.write(
|
|
225
|
+
[
|
|
226
|
+
ASCII_LOGO,
|
|
227
|
+
"",
|
|
228
|
+
`${BOLD}Token Tracker${RESET} ${color("Local-first usage across " + providerCount + " AI CLIs", DIM)}`,
|
|
229
|
+
DIVIDER,
|
|
230
|
+
`${CYAN}Nothing leaves your machine — token counts only, never prompts or responses.${RESET}`,
|
|
231
|
+
DIVIDER,
|
|
232
|
+
"",
|
|
233
|
+
` Tracks: ${providerLine}`,
|
|
234
|
+
` Dashboard: http://localhost:7680`,
|
|
235
|
+
"",
|
|
236
|
+
].join("\n"),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function renderLocalSuccess({ firstSync } = {}) {
|
|
241
|
+
const lines = ["", `${BOLD}Setup complete!${RESET}`, ""];
|
|
242
|
+
|
|
243
|
+
if (firstSync && firstSync.totalTokens > 0) {
|
|
244
|
+
const tokens = firstSync.totalTokens.toLocaleString("en-US");
|
|
245
|
+
const sourceCount = firstSync.sources.length;
|
|
246
|
+
const sourceWord = sourceCount === 1 ? "provider" : "providers";
|
|
247
|
+
lines.push(
|
|
248
|
+
` ${BOLD}${tokens}${RESET} tokens tracked across ${sourceCount} ${sourceWord}.`,
|
|
249
|
+
);
|
|
250
|
+
} else {
|
|
251
|
+
lines.push(
|
|
252
|
+
" No usage history yet — run any AI CLI and tokens appear within a minute.",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
lines.push(
|
|
257
|
+
"",
|
|
258
|
+
` Dashboard: ${CYAN}http://localhost:7680${RESET}`,
|
|
259
|
+
"",
|
|
260
|
+
// One-shot, post-success star CTA. `init` is run once per machine, so
|
|
261
|
+
// this is the only place a CLI user naturally sees the project's GitHub
|
|
262
|
+
// URL — and they're at peak satisfaction.
|
|
263
|
+
` ${color("⭐ Star us if useful: https://github.com/mm7894215/TokenTracker", DIM)}`,
|
|
264
|
+
"",
|
|
265
|
+
);
|
|
266
|
+
process.stdout.write(lines.join("\n"));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function renderAccountNotLinked({ context } = {}) {
|
|
270
|
+
if (context === "dry-run") {
|
|
271
|
+
process.stdout.write(
|
|
272
|
+
[
|
|
273
|
+
"",
|
|
274
|
+
"Dry run complete. Run init without --dry-run to apply changes.",
|
|
275
|
+
"",
|
|
276
|
+
].join("\n"),
|
|
277
|
+
);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
renderLocalSuccess();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function shouldUseBrowserAuth({ deviceToken, opts }) {
|
|
284
|
+
if (deviceToken) return false;
|
|
285
|
+
if (opts.noAuth) return false;
|
|
286
|
+
if (opts.linkCode) return false;
|
|
287
|
+
if (opts.email || opts.password) return false;
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function buildDryRunSummary({ opts, home, trackerDir, notifyPath, runtime }) {
|
|
292
|
+
const deviceToken = runtime?.deviceToken || null;
|
|
293
|
+
const pendingBrowserAuth = shouldUseBrowserAuth({ deviceToken, opts });
|
|
294
|
+
const context = buildIntegrationTargets({ home, trackerDir, notifyPath });
|
|
295
|
+
const summary = await previewIntegrations({ context });
|
|
296
|
+
return { summary, pendingBrowserAuth, deviceToken };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function runSetup({
|
|
300
|
+
opts,
|
|
301
|
+
home,
|
|
302
|
+
trackerDir,
|
|
303
|
+
binDir,
|
|
304
|
+
configPath,
|
|
305
|
+
notifyOriginalPath,
|
|
306
|
+
linkCodeStatePath,
|
|
307
|
+
notifyPath,
|
|
308
|
+
appDir,
|
|
309
|
+
trackerBinPath,
|
|
310
|
+
runtime,
|
|
311
|
+
existingConfig,
|
|
312
|
+
}) {
|
|
313
|
+
await ensureDir(trackerDir);
|
|
314
|
+
await ensureDir(binDir);
|
|
315
|
+
let deviceToken = runtime?.deviceToken || null;
|
|
316
|
+
let deviceId = existingConfig?.deviceId || null;
|
|
317
|
+
const installedAt = existingConfig?.installedAt || new Date().toISOString();
|
|
318
|
+
let pendingBrowserAuth = false;
|
|
319
|
+
|
|
320
|
+
await installLocalTrackerApp({ appDir });
|
|
321
|
+
|
|
322
|
+
const existingPlainConfig =
|
|
323
|
+
existingConfig && typeof existingConfig === "object" && !Array.isArray(existingConfig)
|
|
324
|
+
? existingConfig
|
|
325
|
+
: {};
|
|
326
|
+
const config = {
|
|
327
|
+
...existingPlainConfig,
|
|
328
|
+
installedAt,
|
|
329
|
+
baseUrl: opts.baseUrl || existingPlainConfig.baseUrl || DEFAULT_BASE_URL,
|
|
330
|
+
};
|
|
331
|
+
if (opts.dashboardUrl) {
|
|
332
|
+
config.dashboardUrl = opts.dashboardUrl;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await writeJson(configPath, config);
|
|
336
|
+
await chmod600IfPossible(configPath);
|
|
337
|
+
|
|
338
|
+
await writeFileAtomic(
|
|
339
|
+
notifyPath,
|
|
340
|
+
buildNotifyHandler({ trackerDir, trackerBinPath, packageName: "@ipv9/tokentracker-cli" }),
|
|
341
|
+
);
|
|
342
|
+
await fs.chmod(notifyPath, 0o755).catch(() => {});
|
|
343
|
+
|
|
344
|
+
const summary = await applyIntegrationSetup({
|
|
345
|
+
home,
|
|
346
|
+
trackerDir,
|
|
347
|
+
notifyPath,
|
|
348
|
+
notifyOriginalPath,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
summary,
|
|
353
|
+
pendingBrowserAuth,
|
|
354
|
+
deviceToken,
|
|
355
|
+
deviceId,
|
|
356
|
+
installedAt,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function buildIntegrationTargets({ home, trackerDir, notifyPath }) {
|
|
361
|
+
const codexHome = process.env.CODEX_HOME || path.join(home, ".codex");
|
|
362
|
+
const codexConfigPath = path.join(codexHome, "config.toml");
|
|
363
|
+
const codeHome = process.env.CODE_HOME || path.join(home, ".code");
|
|
364
|
+
const codeConfigPath = path.join(codeHome, "config.toml");
|
|
365
|
+
const notifyOriginalPath = path.join(trackerDir, "codex_notify_original.json");
|
|
366
|
+
const codeNotifyOriginalPath = path.join(trackerDir, "code_notify_original.json");
|
|
367
|
+
const notifyCmd = ["/usr/bin/env", "node", notifyPath];
|
|
368
|
+
const codeNotifyCmd = ["/usr/bin/env", "node", notifyPath, "--source=every-code"];
|
|
369
|
+
const claudeDir = path.join(home, ".claude");
|
|
370
|
+
const claudeSettingsPath = path.join(claudeDir, "settings.json");
|
|
371
|
+
const claudeHookCommand = buildClaudeHookCommand(notifyPath);
|
|
372
|
+
// CodeBuddy CLI (Tencent) is a Claude-Code fork — same settings.json hook
|
|
373
|
+
// schema, same SessionEnd event. We install the same hook with a different
|
|
374
|
+
// --source token so notify.cjs / sync know which provider triggered.
|
|
375
|
+
const codebuddyDir = process.env.CODEBUDDY_HOME || path.join(home, ".codebuddy");
|
|
376
|
+
const codebuddySettingsPath = path.join(codebuddyDir, "settings.json");
|
|
377
|
+
const codebuddyHookCommand = buildHookCommand(notifyPath, "codebuddy");
|
|
378
|
+
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
379
|
+
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
380
|
+
const geminiHookCommand = buildGeminiHookCommand(notifyPath);
|
|
381
|
+
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
trackerDir,
|
|
385
|
+
codexConfigPath,
|
|
386
|
+
codeConfigPath,
|
|
387
|
+
notifyOriginalPath,
|
|
388
|
+
codeNotifyOriginalPath,
|
|
389
|
+
notifyCmd,
|
|
390
|
+
codeNotifyCmd,
|
|
391
|
+
claudeDir,
|
|
392
|
+
claudeSettingsPath,
|
|
393
|
+
claudeHookCommand,
|
|
394
|
+
codebuddyDir,
|
|
395
|
+
codebuddySettingsPath,
|
|
396
|
+
codebuddyHookCommand,
|
|
397
|
+
geminiConfigDir,
|
|
398
|
+
geminiSettingsPath,
|
|
399
|
+
geminiHookCommand,
|
|
400
|
+
opencodeConfigDir,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function applyIntegrationSetup({ home, trackerDir, notifyPath, notifyOriginalPath }) {
|
|
405
|
+
const context = buildIntegrationTargets({ home, trackerDir, notifyPath });
|
|
406
|
+
context.notifyOriginalPath = notifyOriginalPath;
|
|
407
|
+
|
|
408
|
+
const summary = [];
|
|
409
|
+
|
|
410
|
+
const codexProbe = await probeFile(context.codexConfigPath);
|
|
411
|
+
if (codexProbe.exists) {
|
|
412
|
+
const result = await upsertCodexNotify({
|
|
413
|
+
codexConfigPath: context.codexConfigPath,
|
|
414
|
+
notifyCmd: context.notifyCmd,
|
|
415
|
+
notifyOriginalPath: context.notifyOriginalPath,
|
|
416
|
+
});
|
|
417
|
+
summary.push({
|
|
418
|
+
label: "Codex CLI",
|
|
419
|
+
status: result.changed ? "updated" : "set",
|
|
420
|
+
detail: result.changed ? "Updated config" : "Config already set",
|
|
421
|
+
});
|
|
422
|
+
} else {
|
|
423
|
+
summary.push({ label: "Codex CLI", status: "skipped", detail: renderSkipDetail(codexProbe) });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const claudeDirExists = await isDir(context.claudeDir);
|
|
427
|
+
if (claudeDirExists) {
|
|
428
|
+
await upsertClaudeHook({
|
|
429
|
+
settingsPath: context.claudeSettingsPath,
|
|
430
|
+
hookCommand: context.claudeHookCommand,
|
|
431
|
+
});
|
|
432
|
+
summary.push({ label: "Claude", status: "installed", detail: "Hooks installed" });
|
|
433
|
+
} else {
|
|
434
|
+
summary.push({ label: "Claude", status: "skipped", detail: "Config not found" });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const geminiConfigExists = await isDir(context.geminiConfigDir);
|
|
438
|
+
if (geminiConfigExists) {
|
|
439
|
+
await upsertGeminiHook({
|
|
440
|
+
settingsPath: context.geminiSettingsPath,
|
|
441
|
+
hookCommand: context.geminiHookCommand,
|
|
442
|
+
});
|
|
443
|
+
summary.push({ label: "Gemini", status: "installed", detail: "Hooks installed" });
|
|
444
|
+
} else {
|
|
445
|
+
summary.push({ label: "Gemini", status: "skipped", detail: "Config not found" });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const opencodeResult = await upsertOpencodePlugin({
|
|
449
|
+
configDir: context.opencodeConfigDir,
|
|
450
|
+
notifyPath,
|
|
451
|
+
});
|
|
452
|
+
if (opencodeResult?.skippedReason === "config-missing") {
|
|
453
|
+
summary.push({ label: "Opencode Plugin", status: "skipped", detail: "Config not found" });
|
|
454
|
+
} else {
|
|
455
|
+
summary.push({
|
|
456
|
+
label: "Opencode Plugin",
|
|
457
|
+
status: opencodeResult?.changed ? "installed" : "set",
|
|
458
|
+
detail: "Plugin installed",
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Cursor (API-based, no hooks needed)
|
|
463
|
+
if (isCursorInstalled({ home })) {
|
|
464
|
+
const cursorAuth = extractCursorSessionToken({ home });
|
|
465
|
+
if (cursorAuth) {
|
|
466
|
+
summary.push({
|
|
467
|
+
label: "Cursor",
|
|
468
|
+
status: "detected",
|
|
469
|
+
detail: "Usage synced via Cursor API (no hooks needed)",
|
|
470
|
+
});
|
|
471
|
+
} else {
|
|
472
|
+
summary.push({
|
|
473
|
+
label: "Cursor",
|
|
474
|
+
status: "skipped",
|
|
475
|
+
detail: "Installed but not logged in (login in Cursor to enable)",
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
} else {
|
|
479
|
+
summary.push({ label: "Cursor", status: "skipped", detail: "Not installed" });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Kimi: passive reader — no hook installation needed.
|
|
483
|
+
// TokenTracker reads both legacy ~/.kimi/ and official ~/.kimi-code/ sessions.
|
|
484
|
+
{
|
|
485
|
+
const kimiHome = process.env.KIMI_HOME || path.join(home, ".kimi");
|
|
486
|
+
const kimiCodeHome = process.env.KIMI_CODE_HOME || path.join(home, ".kimi-code");
|
|
487
|
+
const fssync = require("node:fs");
|
|
488
|
+
if (fssync.existsSync(path.join(kimiHome, "sessions")) || fssync.existsSync(path.join(kimiCodeHome, "sessions"))) {
|
|
489
|
+
summary.push({ label: "Kimi Code", status: "detected", detail: "Passive reader (no hook needed)" });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// oh-my-pi: passive reader — no hook installation needed.
|
|
494
|
+
// TokenTracker reads ~/.omp/agent/sessions/**/*.jsonl directly.
|
|
495
|
+
{
|
|
496
|
+
const ompSessions = path.join(resolveOmpAgentDir(process.env), "sessions");
|
|
497
|
+
if (fssync.existsSync(ompSessions)) {
|
|
498
|
+
summary.push({ label: "oh-my-pi", status: "detected", detail: "Passive reader (no hook needed)" });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// pi (@mariozechner/pi-coding-agent): passive reader — no hook installation needed.
|
|
503
|
+
// TokenTracker reads ~/.pi/agent/sessions/**/*.jsonl directly. Skip when its
|
|
504
|
+
// agent dir collides with omp's so the summary matches what sync will scan.
|
|
505
|
+
if (!piAgentDirCollidesWithOmp(process.env)) {
|
|
506
|
+
const piSessions = path.join(resolvePiAgentDir(process.env), "sessions");
|
|
507
|
+
if (fssync.existsSync(piSessions)) {
|
|
508
|
+
summary.push({ label: "pi", status: "detected", detail: "Passive reader (no hook needed)" });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Craft Agents: passive reader — no hook installation needed.
|
|
513
|
+
// TokenTracker reads ~/.craft-agent/workspaces/<id>/sessions/**/session.jsonl
|
|
514
|
+
// (and any user-relocated workspace listed in ~/.craft-agent/config.json).
|
|
515
|
+
{
|
|
516
|
+
const craftConfigDir = process.env.CRAFT_CONFIG_DIR || path.join(home, ".craft-agent");
|
|
517
|
+
if (fssync.existsSync(craftConfigDir)) {
|
|
518
|
+
summary.push({ label: "Craft Agents", status: "detected", detail: "Passive reader (no hook needed)" });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Grok Build (xAI): SessionEnd hook in ~/.grok/hooks/ + handler in ~/.tokentracker/bin/
|
|
523
|
+
{
|
|
524
|
+
try {
|
|
525
|
+
const grokState = await probeGrokHookState({ home, trackerDir, env: process.env });
|
|
526
|
+
if (grokState.hasGrokInstall) {
|
|
527
|
+
const grokRes = await upsertGrokHook({ home, trackerDir, env: process.env });
|
|
528
|
+
summary.push({
|
|
529
|
+
label: "Grok Build",
|
|
530
|
+
status: grokRes.configured ? "installed" : "detected",
|
|
531
|
+
detail: grokRes.configured ? "SessionEnd hook installed (99-tokentracker-usage.json)" : "Grok detected"
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
} catch (err) {
|
|
535
|
+
summary.push({ label: "Grok Build", status: "error", detail: String(err?.message || err) });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Kilo CLI (kilo.ai @kilocode/plugin): passive reader — no hook installation
|
|
540
|
+
// needed. Reuses OpenCode-fork SQLite schema at ~/.local/share/kilo/kilo.db
|
|
541
|
+
// (override via KILO_HOME).
|
|
542
|
+
{
|
|
543
|
+
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
|
|
544
|
+
const kiloHome = process.env.KILO_HOME || path.join(xdgDataHome, "kilo");
|
|
545
|
+
const kiloDbPath = path.join(kiloHome, "kilo.db");
|
|
546
|
+
if (fssync.existsSync(kiloDbPath)) {
|
|
547
|
+
summary.push({ label: "Kilo CLI", status: "detected", detail: "Passive reader (no hook needed)" });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Kilo Code VS Code extension (kilocode.kilo-code): passive reader — no hook
|
|
552
|
+
// installation needed. Scans ui_messages.json under every detected VS Code-
|
|
553
|
+
// family install (Code, Cursor, CodeBuddy, Windsurf, …).
|
|
554
|
+
{
|
|
555
|
+
const { resolveKilocodeTaskFiles } = require("../lib/rollout");
|
|
556
|
+
const taskFiles = resolveKilocodeTaskFiles(process.env);
|
|
557
|
+
if (taskFiles.length > 0) {
|
|
558
|
+
const ides = Array.from(new Set(taskFiles.map((t) => t.ide))).join(", ");
|
|
559
|
+
summary.push({
|
|
560
|
+
label: "Kilo Code (VS Code extension)",
|
|
561
|
+
status: "detected",
|
|
562
|
+
detail: `Passive reader · ${taskFiles.length} task${taskFiles.length !== 1 ? "s" : ""} in ${ides}`,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// CodeBuddy: Claude-Code fork. Install the SessionEnd hook so finished
|
|
568
|
+
// sessions trigger notify.cjs → tracker sync; passive scan still runs as a
|
|
569
|
+
// safety net for sessions that don't fire SessionEnd cleanly.
|
|
570
|
+
const codebuddyDirExists = await isDir(context.codebuddyDir);
|
|
571
|
+
if (codebuddyDirExists) {
|
|
572
|
+
await upsertClaudeHook({
|
|
573
|
+
settingsPath: context.codebuddySettingsPath,
|
|
574
|
+
hookCommand: context.codebuddyHookCommand,
|
|
575
|
+
});
|
|
576
|
+
summary.push({ label: "CodeBuddy", status: "installed", detail: "Hooks installed" });
|
|
577
|
+
} else {
|
|
578
|
+
summary.push({ label: "CodeBuddy", status: "skipped", detail: "Config not found" });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const openclawBefore = await probeOpenclawSessionPluginState({
|
|
582
|
+
home,
|
|
583
|
+
trackerDir,
|
|
584
|
+
env: process.env,
|
|
585
|
+
});
|
|
586
|
+
const openclawInstall = await installOpenclawSessionPlugin({
|
|
587
|
+
home,
|
|
588
|
+
trackerDir,
|
|
589
|
+
packageName: "@ipv9/tokentracker-cli",
|
|
590
|
+
env: process.env,
|
|
591
|
+
});
|
|
592
|
+
if (openclawInstall?.skippedReason === "openclaw-cli-missing") {
|
|
593
|
+
summary.push({
|
|
594
|
+
label: "OpenClaw Session Plugin",
|
|
595
|
+
status: "skipped",
|
|
596
|
+
detail: "OpenClaw CLI not found",
|
|
597
|
+
});
|
|
598
|
+
} else if (openclawInstall?.skippedReason === "openclaw-plugins-install-failed") {
|
|
599
|
+
summary.push({
|
|
600
|
+
label: "OpenClaw Session Plugin",
|
|
601
|
+
status: "skipped",
|
|
602
|
+
detail: `Install failed${openclawInstall.error ? `: ${openclawInstall.error}` : ""}`,
|
|
603
|
+
});
|
|
604
|
+
} else if (openclawInstall?.skippedReason === "openclaw-config-unreadable") {
|
|
605
|
+
summary.push({
|
|
606
|
+
label: "OpenClaw Session Plugin",
|
|
607
|
+
status: "skipped",
|
|
608
|
+
detail: openclawInstall.error
|
|
609
|
+
? `OpenClaw config unreadable: ${openclawInstall.error}`
|
|
610
|
+
: "OpenClaw config unreadable",
|
|
611
|
+
});
|
|
612
|
+
} else if (openclawInstall?.configured) {
|
|
613
|
+
summary.push({
|
|
614
|
+
label: "OpenClaw Session Plugin",
|
|
615
|
+
status: openclawBefore?.configured ? "set" : "installed",
|
|
616
|
+
detail: openclawBefore?.configured
|
|
617
|
+
? "Session plugin already linked"
|
|
618
|
+
: "Session plugin linked (restart OpenClaw gateway to activate)",
|
|
619
|
+
});
|
|
620
|
+
} else {
|
|
621
|
+
summary.push({
|
|
622
|
+
label: "OpenClaw Session Plugin",
|
|
623
|
+
status: "skipped",
|
|
624
|
+
detail: "OpenClaw session plugin unavailable",
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const legacyHookState = await probeOpenclawHookState({ home, trackerDir, env: process.env });
|
|
629
|
+
if (legacyHookState?.configured || legacyHookState?.linked || legacyHookState?.enabled) {
|
|
630
|
+
await removeOpenclawHookConfig({ home, trackerDir, env: process.env });
|
|
631
|
+
summary.push({
|
|
632
|
+
label: "OpenClaw Hook (legacy)",
|
|
633
|
+
status: "updated",
|
|
634
|
+
detail: "Removed legacy command hook (migrated to session plugin)",
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const codeProbe = await probeFile(context.codeConfigPath);
|
|
639
|
+
if (codeProbe.exists) {
|
|
640
|
+
const result = await upsertEveryCodeNotify({
|
|
641
|
+
codeConfigPath: context.codeConfigPath,
|
|
642
|
+
notifyCmd: context.codeNotifyCmd,
|
|
643
|
+
notifyOriginalPath: context.codeNotifyOriginalPath,
|
|
644
|
+
});
|
|
645
|
+
summary.push({
|
|
646
|
+
label: "Every Code",
|
|
647
|
+
status: result.changed ? "updated" : "set",
|
|
648
|
+
detail: result.changed ? "Updated config" : "Config already set",
|
|
649
|
+
});
|
|
650
|
+
} else {
|
|
651
|
+
summary.push({ label: "Every Code", status: "skipped", detail: renderSkipDetail(codeProbe) });
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return summary;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function previewIntegrations({ context }) {
|
|
658
|
+
const summary = [];
|
|
659
|
+
const home = os.homedir();
|
|
660
|
+
|
|
661
|
+
const codexProbe = await probeFile(context.codexConfigPath);
|
|
662
|
+
if (codexProbe.exists) {
|
|
663
|
+
const existing = await readCodexNotify(context.codexConfigPath);
|
|
664
|
+
const matches = arraysEqual(existing, context.notifyCmd);
|
|
665
|
+
summary.push({
|
|
666
|
+
label: "Codex CLI",
|
|
667
|
+
status: matches ? "set" : "updated",
|
|
668
|
+
detail: matches ? "Already configured" : "Will update config",
|
|
669
|
+
});
|
|
670
|
+
} else {
|
|
671
|
+
summary.push({ label: "Codex CLI", status: "skipped", detail: renderSkipDetail(codexProbe) });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const claudeDirExists = await isDir(context.claudeDir);
|
|
675
|
+
if (claudeDirExists) {
|
|
676
|
+
const configured = await isClaudeHookConfigured({
|
|
677
|
+
settingsPath: context.claudeSettingsPath,
|
|
678
|
+
hookCommand: context.claudeHookCommand,
|
|
679
|
+
});
|
|
680
|
+
summary.push({
|
|
681
|
+
label: "Claude",
|
|
682
|
+
status: "installed",
|
|
683
|
+
detail: configured ? "Hooks already installed" : "Will install hooks",
|
|
684
|
+
});
|
|
685
|
+
} else {
|
|
686
|
+
summary.push({ label: "Claude", status: "skipped", detail: "Config not found" });
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const codebuddyDirExists = await isDir(context.codebuddyDir);
|
|
690
|
+
if (codebuddyDirExists) {
|
|
691
|
+
const configured = await isClaudeHookConfigured({
|
|
692
|
+
settingsPath: context.codebuddySettingsPath,
|
|
693
|
+
hookCommand: context.codebuddyHookCommand,
|
|
694
|
+
});
|
|
695
|
+
summary.push({
|
|
696
|
+
label: "CodeBuddy",
|
|
697
|
+
status: "installed",
|
|
698
|
+
detail: configured ? "Hooks already installed" : "Will install hooks",
|
|
699
|
+
});
|
|
700
|
+
} else {
|
|
701
|
+
summary.push({ label: "CodeBuddy", status: "skipped", detail: "Config not found" });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const geminiConfigExists = await isDir(context.geminiConfigDir);
|
|
705
|
+
if (geminiConfigExists) {
|
|
706
|
+
const configured = await isGeminiHookConfigured({
|
|
707
|
+
settingsPath: context.geminiSettingsPath,
|
|
708
|
+
hookCommand: context.geminiHookCommand,
|
|
709
|
+
});
|
|
710
|
+
summary.push({
|
|
711
|
+
label: "Gemini",
|
|
712
|
+
status: "installed",
|
|
713
|
+
detail: configured ? "Hooks already installed" : "Will install hooks",
|
|
714
|
+
});
|
|
715
|
+
} else {
|
|
716
|
+
summary.push({ label: "Gemini", status: "skipped", detail: "Config not found" });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const opencodeDirExists = await isDir(context.opencodeConfigDir);
|
|
720
|
+
const installed = await isOpencodePluginInstalled({ configDir: context.opencodeConfigDir });
|
|
721
|
+
const opencodeDetail = installed
|
|
722
|
+
? "Plugin already installed"
|
|
723
|
+
: opencodeDirExists
|
|
724
|
+
? "Will install plugin"
|
|
725
|
+
: "Will create config and install plugin";
|
|
726
|
+
summary.push({
|
|
727
|
+
label: "Opencode Plugin",
|
|
728
|
+
status: "installed",
|
|
729
|
+
detail: opencodeDetail,
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
const openclawState = await probeOpenclawSessionPluginState({
|
|
733
|
+
home,
|
|
734
|
+
trackerDir: context.trackerDir,
|
|
735
|
+
env: process.env,
|
|
736
|
+
});
|
|
737
|
+
if (openclawState?.skippedReason === "openclaw-config-missing") {
|
|
738
|
+
summary.push({
|
|
739
|
+
label: "OpenClaw Session Plugin",
|
|
740
|
+
status: "skipped",
|
|
741
|
+
detail: "OpenClaw config not found",
|
|
742
|
+
});
|
|
743
|
+
} else if (openclawState?.skippedReason === "openclaw-config-unreadable") {
|
|
744
|
+
summary.push({
|
|
745
|
+
label: "OpenClaw Session Plugin",
|
|
746
|
+
status: "skipped",
|
|
747
|
+
detail: openclawState.error
|
|
748
|
+
? `OpenClaw config unreadable: ${openclawState.error}`
|
|
749
|
+
: "OpenClaw config unreadable",
|
|
750
|
+
});
|
|
751
|
+
} else {
|
|
752
|
+
summary.push({
|
|
753
|
+
label: "OpenClaw Session Plugin",
|
|
754
|
+
status: openclawState?.configured ? "set" : "installed",
|
|
755
|
+
detail: openclawState?.configured
|
|
756
|
+
? "Session plugin already linked"
|
|
757
|
+
: "Will link session plugin (restart OpenClaw gateway to activate)",
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const legacyHookState = await probeOpenclawHookState({
|
|
762
|
+
home,
|
|
763
|
+
trackerDir: context.trackerDir,
|
|
764
|
+
env: process.env,
|
|
765
|
+
});
|
|
766
|
+
if (legacyHookState?.configured || legacyHookState?.linked || legacyHookState?.enabled) {
|
|
767
|
+
summary.push({
|
|
768
|
+
label: "OpenClaw Hook (legacy)",
|
|
769
|
+
status: "updated",
|
|
770
|
+
detail: "Will remove legacy command hook during migration",
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const codeProbe = await probeFile(context.codeConfigPath);
|
|
775
|
+
if (codeProbe.exists) {
|
|
776
|
+
const existing = await readEveryCodeNotify(context.codeConfigPath);
|
|
777
|
+
const matches = arraysEqual(existing, context.codeNotifyCmd);
|
|
778
|
+
summary.push({
|
|
779
|
+
label: "Every Code",
|
|
780
|
+
status: matches ? "set" : "updated",
|
|
781
|
+
detail: matches ? "Already configured" : "Will update config",
|
|
782
|
+
});
|
|
783
|
+
} else {
|
|
784
|
+
summary.push({ label: "Every Code", status: "skipped", detail: renderSkipDetail(codeProbe) });
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return summary;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function renderSkipDetail(probe) {
|
|
791
|
+
if (!probe || probe.reason === "missing") return "Config not found";
|
|
792
|
+
if (probe.reason === "permission-denied") return "Permission denied";
|
|
793
|
+
if (probe.reason === "not-file") return "Invalid config";
|
|
794
|
+
return "Unavailable";
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function arraysEqual(a, b) {
|
|
798
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
799
|
+
if (a.length !== b.length) return false;
|
|
800
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function parseArgs(argv) {
|
|
805
|
+
const out = {
|
|
806
|
+
baseUrl: null,
|
|
807
|
+
dashboardUrl: null,
|
|
808
|
+
email: null,
|
|
809
|
+
password: null,
|
|
810
|
+
deviceName: null,
|
|
811
|
+
linkCode: null,
|
|
812
|
+
noAuth: false,
|
|
813
|
+
noOpen: false,
|
|
814
|
+
yes: false,
|
|
815
|
+
dryRun: false,
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
for (let i = 0; i < argv.length; i++) {
|
|
819
|
+
const a = argv[i];
|
|
820
|
+
if (a === "--base-url") out.baseUrl = argv[++i] || null;
|
|
821
|
+
else if (a === "--dashboard-url") out.dashboardUrl = argv[++i] || null;
|
|
822
|
+
else if (a === "--email") out.email = argv[++i] || null;
|
|
823
|
+
else if (a === "--password") out.password = argv[++i] || null;
|
|
824
|
+
else if (a === "--device-name") out.deviceName = argv[++i] || null;
|
|
825
|
+
else if (a === "--link-code") out.linkCode = argv[++i] || null;
|
|
826
|
+
else if (a === "--no-auth") out.noAuth = true;
|
|
827
|
+
else if (a === "--no-open") out.noOpen = true;
|
|
828
|
+
else if (a === "--yes") out.yes = true;
|
|
829
|
+
else if (a === "--dry-run") out.dryRun = true;
|
|
830
|
+
else throw new Error(`Unknown option: ${a}`);
|
|
831
|
+
}
|
|
832
|
+
return out;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function sleep(ms) {
|
|
836
|
+
if (!ms || ms <= 0) return Promise.resolve();
|
|
837
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function normalizePlatform(value) {
|
|
841
|
+
if (value === "darwin") return "macos";
|
|
842
|
+
if (value === "win32") return "windows";
|
|
843
|
+
if (value === "linux") return "linux";
|
|
844
|
+
return "unknown";
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function buildNotifyHandler({ trackerDir, packageName }) {
|
|
848
|
+
// Keep this file dependency-free: Node built-ins only.
|
|
849
|
+
// It must never block Codex; it spawns sync in the background and exits 0.
|
|
850
|
+
const queueSignalPath = path.join(trackerDir, "notify.signal");
|
|
851
|
+
const originalPath = path.join(trackerDir, "codex_notify_original.json");
|
|
852
|
+
const fallbackPkg = packageName || "@ipv9/tokentracker-cli";
|
|
853
|
+
const trackerBinPath = path.join(trackerDir, "app", "bin", "tracker.js");
|
|
854
|
+
|
|
855
|
+
return `#!/usr/bin/env node
|
|
856
|
+
'use strict';
|
|
857
|
+
|
|
858
|
+
const fs = require('node:fs');
|
|
859
|
+
const os = require('node:os');
|
|
860
|
+
const path = require('node:path');
|
|
861
|
+
const cp = require('node:child_process');
|
|
862
|
+
|
|
863
|
+
const rawArgs = process.argv.slice(2);
|
|
864
|
+
let source = 'codex';
|
|
865
|
+
const payloadArgs = [];
|
|
866
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
867
|
+
const arg = rawArgs[i];
|
|
868
|
+
if (arg === '--source') {
|
|
869
|
+
source = rawArgs[i + 1] || source;
|
|
870
|
+
i += 1;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (arg.startsWith('--source=')) {
|
|
874
|
+
source = arg.slice('--source='.length) || source;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
payloadArgs.push(arg);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const trackerDir = ${JSON.stringify(trackerDir)};
|
|
881
|
+
const signalPath = ${JSON.stringify(queueSignalPath)};
|
|
882
|
+
const codexOriginalPath = ${JSON.stringify(originalPath)};
|
|
883
|
+
const codeOriginalPath = ${JSON.stringify(path.join(trackerDir, "code_notify_original.json"))};
|
|
884
|
+
const trackerBinPath = ${JSON.stringify(trackerBinPath)};
|
|
885
|
+
const depsMarkerPath = path.join(trackerDir, 'app', 'bin', 'tracker.js');
|
|
886
|
+
const configPath = path.join(trackerDir, 'config.json');
|
|
887
|
+
const fallbackPkg = ${JSON.stringify(fallbackPkg)};
|
|
888
|
+
const selfPath = path.resolve(__filename);
|
|
889
|
+
const home = os.homedir();
|
|
890
|
+
const debugLogPath = path.join(trackerDir, 'notify.debug.jsonl');
|
|
891
|
+
const debugEnabled = ['1', 'true'].includes((process.env.TOKENTRACKER_NOTIFY_DEBUG || '').toLowerCase());
|
|
892
|
+
const debugMaxBytesRaw = Number.parseInt(process.env.TOKENTRACKER_NOTIFY_DEBUG_MAX_BYTES || '', 10);
|
|
893
|
+
const debugMaxBytes = Number.isFinite(debugMaxBytesRaw) && debugMaxBytesRaw > 0
|
|
894
|
+
? debugMaxBytesRaw
|
|
895
|
+
: 1_000_000;
|
|
896
|
+
|
|
897
|
+
try {
|
|
898
|
+
fs.mkdirSync(trackerDir, { recursive: true });
|
|
899
|
+
fs.writeFileSync(signalPath, new Date().toISOString(), { encoding: 'utf8' });
|
|
900
|
+
} catch (_) {}
|
|
901
|
+
|
|
902
|
+
if (debugEnabled) {
|
|
903
|
+
try {
|
|
904
|
+
let size = 0;
|
|
905
|
+
try {
|
|
906
|
+
size = fs.statSync(debugLogPath).size;
|
|
907
|
+
} catch (err) {
|
|
908
|
+
if (err && err.code !== 'ENOENT') throw err;
|
|
909
|
+
}
|
|
910
|
+
if (size < debugMaxBytes) {
|
|
911
|
+
const entry = {
|
|
912
|
+
ts: new Date().toISOString(),
|
|
913
|
+
source,
|
|
914
|
+
cwd: process.cwd()
|
|
915
|
+
};
|
|
916
|
+
fs.appendFileSync(debugLogPath, JSON.stringify(entry) + os.EOL, 'utf8');
|
|
917
|
+
}
|
|
918
|
+
} catch (_) {}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Throttle spawn: at most once per 20 seconds.
|
|
922
|
+
try {
|
|
923
|
+
const throttlePath = path.join(trackerDir, 'sync.throttle');
|
|
924
|
+
let deviceToken = process.env.TOKENTRACKER_DEVICE_TOKEN || null;
|
|
925
|
+
if (!deviceToken) {
|
|
926
|
+
try {
|
|
927
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
928
|
+
if (cfg && typeof cfg.deviceToken === 'string') deviceToken = cfg.deviceToken;
|
|
929
|
+
} catch (_) {}
|
|
930
|
+
}
|
|
931
|
+
const canSync = Boolean(deviceToken && deviceToken.length > 0);
|
|
932
|
+
const now = Date.now();
|
|
933
|
+
let last = 0;
|
|
934
|
+
try { last = Number(fs.readFileSync(throttlePath, 'utf8')) || 0; } catch (_) {}
|
|
935
|
+
if (canSync && now - last > 20_000) {
|
|
936
|
+
try { fs.writeFileSync(throttlePath, String(now), 'utf8'); } catch (_) {}
|
|
937
|
+
const hasLocalRuntime = fs.existsSync(trackerBinPath);
|
|
938
|
+
const hasLocalDeps = fs.existsSync(depsMarkerPath);
|
|
939
|
+
if (hasLocalRuntime && hasLocalDeps) {
|
|
940
|
+
spawnDetached([process.execPath, trackerBinPath, 'sync', '--auto', '--from-notify']);
|
|
941
|
+
} else {
|
|
942
|
+
spawnDetached(['npx', '--yes', fallbackPkg, 'sync', '--auto', '--from-notify']);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
} catch (_) {}
|
|
946
|
+
|
|
947
|
+
// Chain the original notify if present (Codex/Every Code only).
|
|
948
|
+
try {
|
|
949
|
+
const originalPath =
|
|
950
|
+
source === 'every-code'
|
|
951
|
+
? codeOriginalPath
|
|
952
|
+
: source === 'claude' || source === 'opencode' || source === 'gemini' || source === 'codebuddy'
|
|
953
|
+
? null
|
|
954
|
+
: codexOriginalPath;
|
|
955
|
+
if (originalPath) {
|
|
956
|
+
const original = JSON.parse(fs.readFileSync(originalPath, 'utf8'));
|
|
957
|
+
const cmd = Array.isArray(original?.notify) ? original.notify : null;
|
|
958
|
+
if (cmd && cmd.length > 0 && !isSelfNotify(cmd) && shouldChainNotify(cmd)) {
|
|
959
|
+
const args = cmd.slice(1);
|
|
960
|
+
if (payloadArgs.length > 0) args.push(...payloadArgs);
|
|
961
|
+
spawnDetached([cmd[0], ...args]);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
} catch (_) {}
|
|
965
|
+
|
|
966
|
+
process.exit(0);
|
|
967
|
+
|
|
968
|
+
function spawnDetached(argv) {
|
|
969
|
+
try {
|
|
970
|
+
const child = cp.spawn(argv[0], argv.slice(1), {
|
|
971
|
+
detached: true,
|
|
972
|
+
stdio: 'ignore',
|
|
973
|
+
env: process.env
|
|
974
|
+
});
|
|
975
|
+
child.unref();
|
|
976
|
+
} catch (_) {}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function resolveMaybeHome(p) {
|
|
980
|
+
if (typeof p !== 'string') return null;
|
|
981
|
+
if (p.startsWith('~/')) return path.join(home, p.slice(2));
|
|
982
|
+
return path.resolve(p);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function isSelfNotify(cmd) {
|
|
986
|
+
for (const part of cmd) {
|
|
987
|
+
if (typeof part !== 'string') continue;
|
|
988
|
+
if (!part.includes('notify.cjs')) continue;
|
|
989
|
+
const resolved = resolveMaybeHome(part);
|
|
990
|
+
if (resolved && resolved === selfPath) return true;
|
|
991
|
+
}
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function shouldChainNotify(cmd) {
|
|
996
|
+
if (!Array.isArray(cmd) || cmd.length === 0) return false;
|
|
997
|
+
if (containsSkyComputerUseClient(cmd)) return false;
|
|
998
|
+
return isRunnableCommand(cmd[0]);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function containsSkyComputerUseClient(cmd) {
|
|
1002
|
+
return cmd.some((part) => typeof part === 'string' && part.includes('SkyComputerUseClient'));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function isRunnableCommand(command) {
|
|
1006
|
+
if (typeof command !== 'string' || command.length === 0) return false;
|
|
1007
|
+
const explicitPath = command.startsWith('~/') || command.includes('/');
|
|
1008
|
+
if (!explicitPath) return true;
|
|
1009
|
+
const resolved = resolveMaybeHome(command);
|
|
1010
|
+
if (!resolved) return false;
|
|
1011
|
+
try {
|
|
1012
|
+
fs.accessSync(resolved, fs.constants.X_OK);
|
|
1013
|
+
return true;
|
|
1014
|
+
} catch (_) {
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
`;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
module.exports = { cmdInit, buildNotifyHandler, installLocalTrackerApp };
|
|
1022
|
+
|
|
1023
|
+
async function probeFile(p) {
|
|
1024
|
+
try {
|
|
1025
|
+
const st = await fs.stat(p);
|
|
1026
|
+
if (st.isFile()) return { exists: true, reason: null };
|
|
1027
|
+
return { exists: false, reason: "not-file" };
|
|
1028
|
+
} catch (e) {
|
|
1029
|
+
if (e?.code === "ENOENT" || e?.code === "ENOTDIR") return { exists: false, reason: "missing" };
|
|
1030
|
+
if (e?.code === "EACCES" || e?.code === "EPERM")
|
|
1031
|
+
return { exists: false, reason: "permission-denied" };
|
|
1032
|
+
return { exists: false, reason: "error", code: e?.code || "unknown" };
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function isDir(p) {
|
|
1037
|
+
try {
|
|
1038
|
+
const st = await fs.stat(p);
|
|
1039
|
+
return st.isDirectory();
|
|
1040
|
+
} catch (_e) {
|
|
1041
|
+
return false;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async function installLocalTrackerApp({ appDir }) {
|
|
1046
|
+
// Copy the current package's runtime (bin + src) into ~/.tokentracker so notify can run sync without npx.
|
|
1047
|
+
const packageRoot = path.resolve(__dirname, "../..");
|
|
1048
|
+
const srcFrom = path.join(packageRoot, "src");
|
|
1049
|
+
const binFrom = path.join(packageRoot, "bin", "tracker.js");
|
|
1050
|
+
const packageJsonFrom = path.join(packageRoot, "package.json");
|
|
1051
|
+
const nodeModulesFrom = path.join(packageRoot, "node_modules");
|
|
1052
|
+
const dashboardDistFrom = path.join(packageRoot, "dashboard", "dist");
|
|
1053
|
+
|
|
1054
|
+
// When running from the installed local runtime (or when appDir is symlinked to this package),
|
|
1055
|
+
// source and destination resolve to the same place. Do not delete appDir in that case.
|
|
1056
|
+
if (await pathsPointToSameLocation(packageRoot, appDir)) {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const srcTo = path.join(appDir, "src");
|
|
1061
|
+
const binToDir = path.join(appDir, "bin");
|
|
1062
|
+
const binTo = path.join(binToDir, "tracker.js");
|
|
1063
|
+
const nodeModulesTo = path.join(appDir, "node_modules");
|
|
1064
|
+
const dashboardDistTo = path.join(appDir, "dashboard", "dist");
|
|
1065
|
+
|
|
1066
|
+
await fs.rm(appDir, { recursive: true, force: true }).catch(() => {});
|
|
1067
|
+
await ensureDir(appDir);
|
|
1068
|
+
await fs.cp(srcFrom, srcTo, { recursive: true });
|
|
1069
|
+
await ensureDir(binToDir);
|
|
1070
|
+
await fs.copyFile(binFrom, binTo);
|
|
1071
|
+
await fs.chmod(binTo, 0o755).catch(() => {});
|
|
1072
|
+
await fs.copyFile(packageJsonFrom, path.join(appDir, "package.json")).catch(() => {});
|
|
1073
|
+
if (await isDir(dashboardDistFrom)) {
|
|
1074
|
+
await fs.cp(dashboardDistFrom, dashboardDistTo, { recursive: true });
|
|
1075
|
+
}
|
|
1076
|
+
await copyRuntimeDependencies({ from: nodeModulesFrom, to: nodeModulesTo });
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async function pathsPointToSameLocation(a, b) {
|
|
1080
|
+
const aReal = await safeRealpath(a);
|
|
1081
|
+
const bReal = await safeRealpath(b);
|
|
1082
|
+
if (aReal && bReal) return aReal === bReal;
|
|
1083
|
+
return path.resolve(a) === path.resolve(b);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async function safeRealpath(p) {
|
|
1087
|
+
try {
|
|
1088
|
+
return await fs.realpath(p);
|
|
1089
|
+
} catch (_err) {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Run the first sync inline so we can show the user their real token total
|
|
1095
|
+
// immediately. Caps wall-time at FIRST_SYNC_TIMEOUT_MS — past that we let the
|
|
1096
|
+
// child continue detached and surrender the wait. Returns aggregate stats
|
|
1097
|
+
// derived from queue.jsonl after the wait window closes.
|
|
1098
|
+
const FIRST_SYNC_TIMEOUT_MS = 15_000;
|
|
1099
|
+
|
|
1100
|
+
async function runFirstSyncAndRead({ trackerBinPath, trackerDir, packageName }) {
|
|
1101
|
+
const fallbackPkg = packageName || "@ipv9/tokentracker-cli";
|
|
1102
|
+
const argv = ["sync", "--drain"];
|
|
1103
|
+
const hasLocalRuntime = typeof trackerBinPath === "string" && fssync.existsSync(trackerBinPath);
|
|
1104
|
+
const cmd = hasLocalRuntime
|
|
1105
|
+
? [process.execPath, trackerBinPath, ...argv]
|
|
1106
|
+
: ["npx", "--yes", fallbackPkg, ...argv];
|
|
1107
|
+
|
|
1108
|
+
await new Promise((resolve) => {
|
|
1109
|
+
let settled = false;
|
|
1110
|
+
let timer = null;
|
|
1111
|
+
const settle = () => {
|
|
1112
|
+
if (settled) return;
|
|
1113
|
+
settled = true;
|
|
1114
|
+
if (timer) clearTimeout(timer);
|
|
1115
|
+
resolve();
|
|
1116
|
+
};
|
|
1117
|
+
let child;
|
|
1118
|
+
try {
|
|
1119
|
+
child = cp.spawn(cmd[0], cmd.slice(1), {
|
|
1120
|
+
// detached so we can let it keep running past our timeout — the user
|
|
1121
|
+
// still gets data later via dashboard auto-refresh.
|
|
1122
|
+
detached: true,
|
|
1123
|
+
stdio: "ignore",
|
|
1124
|
+
env: process.env,
|
|
1125
|
+
});
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
if (isDebugEnabled()) {
|
|
1128
|
+
process.stderr.write(`first-sync spawn failed: ${err?.message || err}\n`);
|
|
1129
|
+
}
|
|
1130
|
+
settle();
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
child.on("error", () => settle());
|
|
1134
|
+
child.on("exit", () => settle());
|
|
1135
|
+
timer = setTimeout(() => {
|
|
1136
|
+
try {
|
|
1137
|
+
child.unref();
|
|
1138
|
+
} catch (_e) {}
|
|
1139
|
+
settle();
|
|
1140
|
+
}, FIRST_SYNC_TIMEOUT_MS);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
return readFirstSyncTotals(trackerDir);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function readFirstSyncTotals(trackerDir) {
|
|
1147
|
+
const queuePath = path.join(trackerDir, "queue.jsonl");
|
|
1148
|
+
let raw;
|
|
1149
|
+
try {
|
|
1150
|
+
raw = fssync.readFileSync(queuePath, "utf8");
|
|
1151
|
+
} catch (_e) {
|
|
1152
|
+
return { totalTokens: 0, sources: [] };
|
|
1153
|
+
}
|
|
1154
|
+
let totalTokens = 0;
|
|
1155
|
+
const sources = new Set();
|
|
1156
|
+
// Each sync appends cumulative totals per (source, model, hour_start); keep
|
|
1157
|
+
// the last entry per bucket to match what the dashboard shows.
|
|
1158
|
+
const latest = new Map();
|
|
1159
|
+
for (const line of raw.split("\n")) {
|
|
1160
|
+
const trimmed = line.trim();
|
|
1161
|
+
if (!trimmed) continue;
|
|
1162
|
+
try {
|
|
1163
|
+
const row = JSON.parse(trimmed);
|
|
1164
|
+
const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
|
|
1165
|
+
latest.set(key, row);
|
|
1166
|
+
} catch {
|
|
1167
|
+
// skip malformed
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
for (const row of latest.values()) {
|
|
1171
|
+
const n = Number(row.total_tokens);
|
|
1172
|
+
if (Number.isFinite(n) && n > 0) totalTokens += n;
|
|
1173
|
+
if (row.source) sources.add(row.source);
|
|
1174
|
+
}
|
|
1175
|
+
return { totalTokens, sources: Array.from(sources) };
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
async function copyRuntimeDependencies({ from, to }) {
|
|
1179
|
+
try {
|
|
1180
|
+
const st = await fs.stat(from);
|
|
1181
|
+
if (!st.isDirectory()) return;
|
|
1182
|
+
} catch (_e) {
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
try {
|
|
1187
|
+
await fs.cp(from, to, { recursive: true });
|
|
1188
|
+
} catch (_e) {
|
|
1189
|
+
// Best-effort: missing dependencies will fall back to npx at notify time.
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function isDebugEnabled() {
|
|
1194
|
+
return process.env.TOKENTRACKER_DEBUG === "1";
|
|
1195
|
+
}
|