@tickrmeter/ai-usage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/dist/claude-statusline.js +41 -0
- package/dist/claude.js +151 -0
- package/dist/cli.js +152 -0
- package/dist/codex.js +279 -0
- package/dist/http.js +43 -0
- package/dist/scheduler.js +175 -0
- package/dist/storage.js +84 -0
- package/dist/sync.js +38 -0
- package/dist/types.js +2 -0
- package/package.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# TickrMeter AI Usage CLI
|
|
2
|
+
|
|
3
|
+
Privacy-preserving personal subscription monitoring for Codex / ChatGPT and Claude Code.
|
|
4
|
+
|
|
5
|
+
## Customer flow
|
|
6
|
+
|
|
7
|
+
1. Open **AI Usage** in TickrMeter and choose **Connect personal usage**.
|
|
8
|
+
2. Copy the one-time command shown by TickrMeter.
|
|
9
|
+
3. Run it on the computer where Codex or Claude Code is installed:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @tickrmeter/ai-usage connect ABCD-927F
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
4. The helper detects the Codex desktop app, Codex CLI, and Claude Code, uploads normalized limit metadata, and installs a user-scoped five-minute sync using Task Scheduler, launchd, or a systemd user timer (cron fallback).
|
|
16
|
+
|
|
17
|
+
The initial `npx` command installs a fixed copy under `~/.tickrmeter-ai/app`. Scheduled runs invoke that fixed version, never `npx ...@latest`.
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
tickrmeter-ai connect <pairing-code> [--server URL]
|
|
23
|
+
tickrmeter-ai sync
|
|
24
|
+
tickrmeter-ai status
|
|
25
|
+
tickrmeter-ai doctor
|
|
26
|
+
tickrmeter-ai disconnect
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For an unpublished local build:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd tickrmeter-ai-usage
|
|
33
|
+
npm install
|
|
34
|
+
npm run build
|
|
35
|
+
node dist/cli.js connect ABCD-927F --server https://develop.tickrmeter.io
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`TICKRMETER_API_URL` may be used instead of `--server`. The production default is `https://api.tickrmeter.io`.
|
|
39
|
+
|
|
40
|
+
## Codex
|
|
41
|
+
|
|
42
|
+
Codex must already be installed and signed in. On Windows, the helper automatically uses the signed-in Codex desktop app when available and falls back to an installed Codex CLI. It starts `codex app-server`, performs the JSON-RPC initialization handshake, calls `account/read` with token refresh disabled, calls `account/rateLimits/read`, and optionally calls `account/usage/read`. It terminates the child process after each sync.
|
|
43
|
+
|
|
44
|
+
The helper does not read `~/.codex/auth.json`, browser cookies, OAuth tokens, or private ChatGPT endpoints. If setup is incomplete, `tickrmeter-ai doctor` directs the user to `codex login`; it never asks for a token.
|
|
45
|
+
|
|
46
|
+
## Claude Code
|
|
47
|
+
|
|
48
|
+
Claude Code 2.1.80 or newer is required. Claude supplies structured `rate_limits.five_hour` and `rate_limits.seven_day` values to its configured status-line command. TickrMeter stores only those percentages, reset timestamps, and `capturedAt` in `~/.tickrmeter-ai/claude-usage.json`.
|
|
49
|
+
|
|
50
|
+
If a status line already exists, its complete configuration is backed up locally and wrapped. The same input is passed to the existing command and its stdout/stderr are preserved. Disconnect restores the exact prior `statusLine` value. Prompts, conversations, transcript paths, project paths, cookies, and OAuth credentials are neither stored nor uploaded.
|
|
51
|
+
|
|
52
|
+
Claude data older than 60 minutes is marked stale. The helper does not send dummy prompts to refresh usage.
|
|
53
|
+
|
|
54
|
+
## Local files
|
|
55
|
+
|
|
56
|
+
Files live under `~/.tickrmeter-ai/` with restrictive permissions where supported:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
config.json TickrMeter bridge ID/token and server
|
|
60
|
+
state.json scheduler and reversible Claude wrapper state
|
|
61
|
+
claude-usage.json normalized Claude rate-limit snapshot only
|
|
62
|
+
app/ fixed installed CLI version
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The bridge token is scoped to AI usage upload/status/disconnect. It is not a TickrMeter user session and cannot access provider credentials.
|
|
66
|
+
|
|
67
|
+
## API usage
|
|
68
|
+
|
|
69
|
+
OpenAI and Anthropic organization API monitoring does not run in this CLI. Admin keys are entered directly in TickrMeter, encrypted by the backend, and used only for official provider usage/cost APIs. They are never returned to this helper.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const claude_1 = require("./claude");
|
|
6
|
+
const storage_1 = require("./storage");
|
|
7
|
+
const chunks = [];
|
|
8
|
+
process.stdin.on("data", (chunk) => {
|
|
9
|
+
if (chunks.reduce((sum, item) => sum + item.length, 0) < 2 * 1024 * 1024)
|
|
10
|
+
chunks.push(chunk);
|
|
11
|
+
});
|
|
12
|
+
process.stdin.on("end", () => {
|
|
13
|
+
const input = Buffer.concat(chunks).toString("utf8");
|
|
14
|
+
try {
|
|
15
|
+
const snapshot = (0, claude_1.extractClaudeSnapshot)(JSON.parse(input));
|
|
16
|
+
if (snapshot)
|
|
17
|
+
(0, storage_1.atomicWriteJson)((0, storage_1.getClaudeCapturePath)(), snapshot);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Claude status output must never be disrupted by capture failures.
|
|
21
|
+
}
|
|
22
|
+
const previous = (0, storage_1.readState)().claude?.previousStatusLine;
|
|
23
|
+
if (previous?.command && typeof previous.command === "string") {
|
|
24
|
+
const environment = previous.env && typeof previous.env === "object"
|
|
25
|
+
? { ...process.env, ...Object.fromEntries(Object.entries(previous.env).map(([key, value]) => [key, String(value)])) }
|
|
26
|
+
: process.env;
|
|
27
|
+
const result = (0, child_process_1.spawnSync)(previous.command, {
|
|
28
|
+
shell: true,
|
|
29
|
+
input,
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
env: environment,
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
});
|
|
34
|
+
if (result.stdout)
|
|
35
|
+
process.stdout.write(result.stdout);
|
|
36
|
+
if (result.stderr)
|
|
37
|
+
process.stderr.write(result.stderr);
|
|
38
|
+
process.exitCode = result.status || 0;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
process.stdin.resume();
|
package/dist/claude.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getClaudeUsage = exports.uninstallClaudeStatusLine = exports.installClaudeStatusLine = exports.getClaudeWrapperCommand = exports.readClaudeCapture = exports.extractClaudeSnapshot = exports.detectClaudeVersion = exports.isClaudeVersionSupported = exports.getClaudeSettingsPath = exports.MINIMUM_CLAUDE_VERSION = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
11
|
+
const storage_1 = require("./storage");
|
|
12
|
+
exports.MINIMUM_CLAUDE_VERSION = [2, 1, 80];
|
|
13
|
+
const getClaudeSettingsPath = () => process.env.TICKRMETER_CLAUDE_SETTINGS ||
|
|
14
|
+
path_1.default.join(process.env.CLAUDE_CONFIG_DIR || path_1.default.join(os_1.default.homedir(), ".claude"), "settings.json");
|
|
15
|
+
exports.getClaudeSettingsPath = getClaudeSettingsPath;
|
|
16
|
+
const parseVersion = (value) => {
|
|
17
|
+
const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
18
|
+
return match ? match.slice(1).map(Number) : null;
|
|
19
|
+
};
|
|
20
|
+
const isClaudeVersionSupported = (version) => {
|
|
21
|
+
const parsed = parseVersion(version);
|
|
22
|
+
if (!parsed)
|
|
23
|
+
return false;
|
|
24
|
+
for (let index = 0; index < exports.MINIMUM_CLAUDE_VERSION.length; index += 1) {
|
|
25
|
+
if (parsed[index] > exports.MINIMUM_CLAUDE_VERSION[index])
|
|
26
|
+
return true;
|
|
27
|
+
if (parsed[index] < exports.MINIMUM_CLAUDE_VERSION[index])
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
};
|
|
32
|
+
exports.isClaudeVersionSupported = isClaudeVersionSupported;
|
|
33
|
+
const detectClaudeVersion = () => {
|
|
34
|
+
const result = (0, child_process_1.spawnSync)("claude", ["--version"], { encoding: "utf8", windowsHide: true });
|
|
35
|
+
if (result.error || result.status !== 0)
|
|
36
|
+
return null;
|
|
37
|
+
return result.stdout.trim() || result.stderr.trim() || null;
|
|
38
|
+
};
|
|
39
|
+
exports.detectClaudeVersion = detectClaudeVersion;
|
|
40
|
+
const finitePercent = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100 ? value : undefined;
|
|
41
|
+
const resetIso = (value) => {
|
|
42
|
+
if (typeof value !== "string" && typeof value !== "number")
|
|
43
|
+
return undefined;
|
|
44
|
+
const date = new Date(value);
|
|
45
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
46
|
+
};
|
|
47
|
+
const extractClaudeSnapshot = (input, capturedAt = new Date()) => {
|
|
48
|
+
const limits = input?.rate_limits;
|
|
49
|
+
if (!limits || typeof limits !== "object")
|
|
50
|
+
return null;
|
|
51
|
+
const windows = [
|
|
52
|
+
{ id: "five_hour", label: "5 hour", duration: 300, value: limits.five_hour },
|
|
53
|
+
{ id: "seven_day", label: "Weekly", duration: 10080, value: limits.seven_day },
|
|
54
|
+
]
|
|
55
|
+
.map(({ id, label, duration, value }) => {
|
|
56
|
+
const usedPercent = finitePercent(value?.used_percentage);
|
|
57
|
+
if (usedPercent === undefined)
|
|
58
|
+
return null;
|
|
59
|
+
return {
|
|
60
|
+
id,
|
|
61
|
+
label,
|
|
62
|
+
usedPercent,
|
|
63
|
+
remainingPercent: 100 - usedPercent,
|
|
64
|
+
windowDurationMins: duration,
|
|
65
|
+
resetsAt: resetIso(value?.resets_at),
|
|
66
|
+
};
|
|
67
|
+
})
|
|
68
|
+
.filter((value) => Boolean(value));
|
|
69
|
+
if (!windows.length)
|
|
70
|
+
return null;
|
|
71
|
+
return {
|
|
72
|
+
source: "claude",
|
|
73
|
+
billingMode: "subscription",
|
|
74
|
+
windows,
|
|
75
|
+
capturedAt: capturedAt.toISOString(),
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
exports.extractClaudeSnapshot = extractClaudeSnapshot;
|
|
79
|
+
const readClaudeCapture = () => {
|
|
80
|
+
const snapshot = (0, storage_1.readJson)((0, storage_1.getClaudeCapturePath)());
|
|
81
|
+
if (!snapshot || snapshot.source !== "claude" || Number.isNaN(Date.parse(snapshot.capturedAt)))
|
|
82
|
+
return null;
|
|
83
|
+
return { ...snapshot, stale: Date.now() - Date.parse(snapshot.capturedAt) > 60 * 60 * 1000 };
|
|
84
|
+
};
|
|
85
|
+
exports.readClaudeCapture = readClaudeCapture;
|
|
86
|
+
const quote = (value) => `"${value.replace(/"/g, '\\"')}"`;
|
|
87
|
+
const getClaudeWrapperCommand = () => `${quote(process.execPath)} ${quote((0, storage_1.getInstalledStatusLinePath)())}`;
|
|
88
|
+
exports.getClaudeWrapperCommand = getClaudeWrapperCommand;
|
|
89
|
+
const installClaudeStatusLine = (detectedVersion) => {
|
|
90
|
+
const version = detectedVersion === undefined ? (0, exports.detectClaudeVersion)() : detectedVersion;
|
|
91
|
+
if (!version)
|
|
92
|
+
return { installed: false, code: "CLAUDE_NOT_INSTALLED" };
|
|
93
|
+
if (!(0, exports.isClaudeVersionSupported)(version))
|
|
94
|
+
return { installed: false, code: "CLAUDE_VERSION_UNSUPPORTED", version };
|
|
95
|
+
const settingsPath = (0, exports.getClaudeSettingsPath)();
|
|
96
|
+
const settings = (0, storage_1.readJson)(settingsPath) || {};
|
|
97
|
+
const state = (0, storage_1.readState)();
|
|
98
|
+
const wrapperCommand = (0, exports.getClaudeWrapperCommand)();
|
|
99
|
+
if (settings.statusLine?.command === wrapperCommand && state.claude) {
|
|
100
|
+
return { installed: true, idempotent: true, version };
|
|
101
|
+
}
|
|
102
|
+
fs_1.default.mkdirSync(path_1.default.dirname(settingsPath), { recursive: true });
|
|
103
|
+
(0, storage_1.atomicWriteJson)(path_1.default.join((0, storage_1.getDataDir)(), "claude-settings.backup.json"), settings);
|
|
104
|
+
const hadStatusLine = Object.prototype.hasOwnProperty.call(settings, "statusLine");
|
|
105
|
+
const previousStatusLine = hadStatusLine ? settings.statusLine : undefined;
|
|
106
|
+
const nextStatusLine = {
|
|
107
|
+
...(previousStatusLine && typeof previousStatusLine === "object" ? previousStatusLine : {}),
|
|
108
|
+
type: "command",
|
|
109
|
+
command: wrapperCommand,
|
|
110
|
+
};
|
|
111
|
+
(0, storage_1.atomicWriteJson)(settingsPath, { ...settings, statusLine: nextStatusLine });
|
|
112
|
+
(0, storage_1.writeState)({
|
|
113
|
+
...state,
|
|
114
|
+
claude: { settingsPath, hadStatusLine, previousStatusLine, wrapperCommand, installedAt: new Date().toISOString() },
|
|
115
|
+
});
|
|
116
|
+
return { installed: true, idempotent: false, version };
|
|
117
|
+
};
|
|
118
|
+
exports.installClaudeStatusLine = installClaudeStatusLine;
|
|
119
|
+
const uninstallClaudeStatusLine = () => {
|
|
120
|
+
const state = (0, storage_1.readState)();
|
|
121
|
+
const claude = state.claude;
|
|
122
|
+
if (!claude)
|
|
123
|
+
return { removed: false, idempotent: true };
|
|
124
|
+
const settings = (0, storage_1.readJson)(claude.settingsPath) || {};
|
|
125
|
+
if (settings.statusLine?.command === claude.wrapperCommand) {
|
|
126
|
+
const next = { ...settings };
|
|
127
|
+
if (claude.hadStatusLine)
|
|
128
|
+
next.statusLine = claude.previousStatusLine;
|
|
129
|
+
else
|
|
130
|
+
delete next.statusLine;
|
|
131
|
+
(0, storage_1.atomicWriteJson)(claude.settingsPath, next);
|
|
132
|
+
}
|
|
133
|
+
const { claude: _removed, ...rest } = state;
|
|
134
|
+
(0, storage_1.writeState)(rest);
|
|
135
|
+
return { removed: true, idempotent: false };
|
|
136
|
+
};
|
|
137
|
+
exports.uninstallClaudeStatusLine = uninstallClaudeStatusLine;
|
|
138
|
+
const getClaudeUsage = () => {
|
|
139
|
+
const version = (0, exports.detectClaudeVersion)();
|
|
140
|
+
if (!version)
|
|
141
|
+
return { ok: false, code: "CLAUDE_NOT_INSTALLED", message: "Claude Code is not installed." };
|
|
142
|
+
if (!(0, exports.isClaudeVersionSupported)(version)) {
|
|
143
|
+
return { ok: false, code: "CLAUDE_VERSION_UNSUPPORTED", message: "Update Claude Code to version 2.1.80 or newer.", version };
|
|
144
|
+
}
|
|
145
|
+
const snapshot = (0, exports.readClaudeCapture)();
|
|
146
|
+
if (!snapshot) {
|
|
147
|
+
return { ok: false, code: "CLAUDE_NOT_AVAILABLE", message: "No Claude rate-limit capture is available yet. Start Claude Code normally once.", version };
|
|
148
|
+
}
|
|
149
|
+
return { ok: true, snapshot, version };
|
|
150
|
+
};
|
|
151
|
+
exports.getClaudeUsage = getClaudeUsage;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const claude_1 = require("./claude");
|
|
5
|
+
const codex_1 = require("./codex");
|
|
6
|
+
const http_1 = require("./http");
|
|
7
|
+
const scheduler_1 = require("./scheduler");
|
|
8
|
+
const storage_1 = require("./storage");
|
|
9
|
+
const sync_1 = require("./sync");
|
|
10
|
+
const DEFAULT_SERVER = "https://api.tickrmeter.io";
|
|
11
|
+
const parseArguments = () => {
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
let server = process.env.TICKRMETER_API_URL || DEFAULT_SERVER;
|
|
14
|
+
const serverIndex = args.indexOf("--server");
|
|
15
|
+
if (serverIndex >= 0) {
|
|
16
|
+
if (!args[serverIndex + 1])
|
|
17
|
+
throw new Error("--server requires a URL");
|
|
18
|
+
server = args[serverIndex + 1];
|
|
19
|
+
args.splice(serverIndex, 2);
|
|
20
|
+
}
|
|
21
|
+
return { command: args[0] || "help", values: args.slice(1), server };
|
|
22
|
+
};
|
|
23
|
+
const printProvider = (provider) => {
|
|
24
|
+
if (!provider.ok) {
|
|
25
|
+
console.log(`- ${provider.code}: ${provider.message}`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const windows = provider.snapshot.windows || [];
|
|
29
|
+
console.log(`- ${provider.snapshot.source}${provider.version ? ` (${provider.version})` : ""}`);
|
|
30
|
+
if (provider.snapshot.plan)
|
|
31
|
+
console.log(` Plan: ${provider.snapshot.plan}`);
|
|
32
|
+
for (const window of windows) {
|
|
33
|
+
const remaining = window.remainingPercent === undefined ? "unavailable" : `${window.remainingPercent.toFixed(0)}% remaining`;
|
|
34
|
+
console.log(` ${window.label || window.id}: ${remaining}${window.resetsAt ? `, resets ${window.resetsAt}` : ""}`);
|
|
35
|
+
}
|
|
36
|
+
if (provider.snapshot.stale)
|
|
37
|
+
console.log(` Capture is stale (${provider.snapshot.capturedAt})`);
|
|
38
|
+
};
|
|
39
|
+
const connect = async (code, server) => {
|
|
40
|
+
if (!code)
|
|
41
|
+
throw new Error("Usage: tickrmeter-ai connect <pairing-code> [--server URL]");
|
|
42
|
+
const exchanged = await (0, http_1.exchangePairingCode)(server, code, storage_1.CLI_VERSION);
|
|
43
|
+
if (!exchanged?.bridgeId || !exchanged?.bridgeToken)
|
|
44
|
+
throw new Error("AI_USAGE_PAIRING_INVALID");
|
|
45
|
+
(0, storage_1.writeConfig)({
|
|
46
|
+
server,
|
|
47
|
+
bridgeId: String(exchanged.bridgeId),
|
|
48
|
+
bridgeToken: exchanged.bridgeToken,
|
|
49
|
+
cliVersion: storage_1.CLI_VERSION,
|
|
50
|
+
connectedAt: new Date().toISOString(),
|
|
51
|
+
});
|
|
52
|
+
(0, storage_1.installFixedCliCopy)();
|
|
53
|
+
const claudeInstall = (0, claude_1.installClaudeStatusLine)();
|
|
54
|
+
const scheduler = (0, scheduler_1.installScheduler)();
|
|
55
|
+
console.log("TickrMeter AI Usage connected.");
|
|
56
|
+
console.log(`Bridge: ${exchanged.bridgeId}`);
|
|
57
|
+
console.log(`Scheduled sync: every 5 minutes (${scheduler.kind})`);
|
|
58
|
+
if (claudeInstall.installed)
|
|
59
|
+
console.log(`Claude Code capture installed (${claudeInstall.version}).`);
|
|
60
|
+
else
|
|
61
|
+
console.log(`Claude Code capture not installed: ${claudeInstall.code}`);
|
|
62
|
+
const report = await (0, sync_1.syncPersonalUsage)();
|
|
63
|
+
report.providers.forEach(printProvider);
|
|
64
|
+
if (!report.uploaded.length)
|
|
65
|
+
console.log("No provider snapshot was available yet. Run tickrmeter-ai doctor for setup help.");
|
|
66
|
+
};
|
|
67
|
+
const sync = async () => {
|
|
68
|
+
if ((0, storage_1.readConfig)())
|
|
69
|
+
(0, storage_1.installFixedCliCopy)();
|
|
70
|
+
const report = await (0, sync_1.syncPersonalUsage)();
|
|
71
|
+
console.log(report.uploaded.length ? `Uploaded: ${report.uploaded.join(", ")}` : "No snapshots available to upload.");
|
|
72
|
+
report.providers.filter((provider) => !provider.ok).forEach(printProvider);
|
|
73
|
+
};
|
|
74
|
+
const status = async () => {
|
|
75
|
+
const result = await (0, sync_1.getLocalStatus)();
|
|
76
|
+
console.log("TickrMeter AI Usage");
|
|
77
|
+
console.log(`Connection: ${result.connected ? result.bridge?.status || "connected" : "not connected"}`);
|
|
78
|
+
if (result.bridgeId)
|
|
79
|
+
console.log(`Bridge: ${result.bridgeId}`);
|
|
80
|
+
result.providers.forEach(printProvider);
|
|
81
|
+
};
|
|
82
|
+
const doctor = async () => {
|
|
83
|
+
if ((0, storage_1.readConfig)())
|
|
84
|
+
(0, storage_1.installFixedCliCopy)();
|
|
85
|
+
const codex = (0, codex_1.detectCodexVersion)();
|
|
86
|
+
const claude = (0, claude_1.detectClaudeVersion)();
|
|
87
|
+
console.log("TickrMeter AI Usage doctor");
|
|
88
|
+
console.log(codex
|
|
89
|
+
? `Codex detected: ${codex}`
|
|
90
|
+
: "Codex app or CLI not detected. Install Codex and sign in with ChatGPT.");
|
|
91
|
+
if (!claude)
|
|
92
|
+
console.log("Claude Code not installed.");
|
|
93
|
+
else if (!(0, claude_1.isClaudeVersionSupported)(claude))
|
|
94
|
+
console.log(`Claude Code ${claude} is too old. Update to 2.1.80 or newer.`);
|
|
95
|
+
else
|
|
96
|
+
console.log(`Claude Code detected: ${claude}`);
|
|
97
|
+
console.log((0, storage_1.readConfig)() ? "TickrMeter bridge credential is installed." : "Not paired. Create a pairing code in TickrMeter first.");
|
|
98
|
+
if ((0, storage_1.readConfig)()) {
|
|
99
|
+
try {
|
|
100
|
+
const report = await (0, sync_1.syncPersonalUsage)();
|
|
101
|
+
report.providers.forEach(printProvider);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.log(`Bridge check failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const disconnect = async () => {
|
|
109
|
+
const config = (0, storage_1.readConfig)();
|
|
110
|
+
if (config) {
|
|
111
|
+
try {
|
|
112
|
+
await (0, http_1.revokeBridge)(config);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
console.log("Bridge could not be revoked online; local credentials will still be removed.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
(0, scheduler_1.uninstallScheduler)();
|
|
119
|
+
(0, claude_1.uninstallClaudeStatusLine)();
|
|
120
|
+
(0, storage_1.removeLocalBridgeCredential)();
|
|
121
|
+
console.log("TickrMeter AI Usage disconnected. Codex and Claude Code were left installed and untouched.");
|
|
122
|
+
};
|
|
123
|
+
const help = () => {
|
|
124
|
+
console.log(`TickrMeter AI Usage ${storage_1.CLI_VERSION}
|
|
125
|
+
|
|
126
|
+
Commands:
|
|
127
|
+
tickrmeter-ai connect <pairing-code> [--server URL]
|
|
128
|
+
tickrmeter-ai sync
|
|
129
|
+
tickrmeter-ai status
|
|
130
|
+
tickrmeter-ai doctor
|
|
131
|
+
tickrmeter-ai disconnect
|
|
132
|
+
|
|
133
|
+
TICKRMETER_API_URL may be used instead of --server.`);
|
|
134
|
+
};
|
|
135
|
+
const main = async () => {
|
|
136
|
+
const { command, values, server } = parseArguments();
|
|
137
|
+
if (command === "connect")
|
|
138
|
+
return connect(values[0], server);
|
|
139
|
+
if (command === "sync")
|
|
140
|
+
return sync();
|
|
141
|
+
if (command === "status")
|
|
142
|
+
return status();
|
|
143
|
+
if (command === "doctor")
|
|
144
|
+
return doctor();
|
|
145
|
+
if (command === "disconnect")
|
|
146
|
+
return disconnect();
|
|
147
|
+
return help();
|
|
148
|
+
};
|
|
149
|
+
main().catch((error) => {
|
|
150
|
+
console.error(error instanceof Error ? error.message : "TickrMeter AI Usage failed.");
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
});
|
package/dist/codex.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.collectCodexUsage = exports.detectCodexVersion = exports.normalizeCodexSnapshot = exports.findWindowsCodexDesktopExecutable = void 0;
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const os_1 = __importDefault(require("os"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const windowLabel = (duration) => {
|
|
12
|
+
if (duration === 300)
|
|
13
|
+
return "5 hour";
|
|
14
|
+
if (duration === 10080)
|
|
15
|
+
return "Weekly";
|
|
16
|
+
if (Number.isFinite(duration))
|
|
17
|
+
return `${duration} minutes`;
|
|
18
|
+
return undefined;
|
|
19
|
+
};
|
|
20
|
+
const parseReset = (value) => {
|
|
21
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
22
|
+
const date = new Date(value < 10_000_000_000 ? value * 1000 : value);
|
|
23
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === "string" && !Number.isNaN(Date.parse(value)))
|
|
26
|
+
return new Date(value).toISOString();
|
|
27
|
+
return undefined;
|
|
28
|
+
};
|
|
29
|
+
const normalizeWindow = (id, value) => {
|
|
30
|
+
const used = value?.usedPercent;
|
|
31
|
+
if (typeof used !== "number" || !Number.isFinite(used) || used < 0 || used > 100)
|
|
32
|
+
return null;
|
|
33
|
+
const duration = value?.windowDurationMins;
|
|
34
|
+
const windowDurationMins = typeof duration === "number" && Number.isFinite(duration) && duration > 0 ? duration : undefined;
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
label: windowLabel(windowDurationMins) || (typeof value?.label === "string" ? value.label.slice(0, 40) : undefined),
|
|
38
|
+
usedPercent: used,
|
|
39
|
+
remainingPercent: 100 - used,
|
|
40
|
+
windowDurationMins,
|
|
41
|
+
resetsAt: parseReset(value?.resetsAt),
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
const collectWindows = (rateResponse) => {
|
|
45
|
+
const candidates = [];
|
|
46
|
+
const addRateLimits = (prefix, limits) => {
|
|
47
|
+
if (!limits || typeof limits !== "object" || Array.isArray(limits))
|
|
48
|
+
return;
|
|
49
|
+
for (const [key, value] of Object.entries(limits)) {
|
|
50
|
+
if (value && typeof value === "object" && "usedPercent" in value)
|
|
51
|
+
candidates.push([`${prefix}${key}`, value]);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
addRateLimits("", rateResponse?.rateLimits);
|
|
55
|
+
const byId = rateResponse?.rateLimitsByLimitId;
|
|
56
|
+
if (byId && typeof byId === "object") {
|
|
57
|
+
for (const [limitId, entry] of Object.entries(byId)) {
|
|
58
|
+
if (entry && typeof entry === "object" && "usedPercent" in entry)
|
|
59
|
+
candidates.push([limitId, entry]);
|
|
60
|
+
addRateLimits(`${limitId}:`, entry?.rateLimits || entry);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
return candidates
|
|
65
|
+
.map(([id, value]) => normalizeWindow(id, value))
|
|
66
|
+
.filter((window) => {
|
|
67
|
+
if (!window)
|
|
68
|
+
return false;
|
|
69
|
+
const identity = `${window.windowDurationMins || "unknown"}:${window.resetsAt || window.id}`;
|
|
70
|
+
if (seen.has(identity))
|
|
71
|
+
return false;
|
|
72
|
+
seen.add(identity);
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const safeCount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
77
|
+
const findWindowsCodexDesktopExecutable = (rootDirectory) => {
|
|
78
|
+
try {
|
|
79
|
+
const candidates = fs_1.default
|
|
80
|
+
.readdirSync(rootDirectory, { withFileTypes: true })
|
|
81
|
+
.filter((entry) => entry.isDirectory())
|
|
82
|
+
.map((entry) => path_1.default.join(rootDirectory, entry.name, "codex.exe"))
|
|
83
|
+
.filter((candidate) => fs_1.default.existsSync(candidate))
|
|
84
|
+
.map((candidate) => ({ candidate, modifiedAt: fs_1.default.statSync(candidate).mtimeMs }))
|
|
85
|
+
.sort((left, right) => right.modifiedAt - left.modifiedAt);
|
|
86
|
+
return candidates[0]?.candidate || null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
exports.findWindowsCodexDesktopExecutable = findWindowsCodexDesktopExecutable;
|
|
93
|
+
const resolveCodexInvocation = () => {
|
|
94
|
+
const explicitPath = process.env.TICKRMETER_CODEX_PATH?.trim();
|
|
95
|
+
if (explicitPath) {
|
|
96
|
+
return explicitPath.toLowerCase().endsWith(".js")
|
|
97
|
+
? { command: process.execPath, prefixArgs: [explicitPath] }
|
|
98
|
+
: { command: explicitPath, prefixArgs: [] };
|
|
99
|
+
}
|
|
100
|
+
if (process.platform !== "win32")
|
|
101
|
+
return { command: "codex", prefixArgs: [] };
|
|
102
|
+
const desktopExecutable = process.env.LOCALAPPDATA
|
|
103
|
+
? (0, exports.findWindowsCodexDesktopExecutable)(path_1.default.join(process.env.LOCALAPPDATA, "OpenAI", "Codex", "bin"))
|
|
104
|
+
: null;
|
|
105
|
+
if (desktopExecutable)
|
|
106
|
+
return { command: desktopExecutable, prefixArgs: [] };
|
|
107
|
+
const searchDirectories = [
|
|
108
|
+
path_1.default.dirname(process.execPath),
|
|
109
|
+
...(process.env.PATH || "").split(path_1.default.delimiter).filter(Boolean),
|
|
110
|
+
];
|
|
111
|
+
if (process.env.APPDATA)
|
|
112
|
+
searchDirectories.push(path_1.default.join(process.env.APPDATA, "npm"));
|
|
113
|
+
const fnmDirectory = process.env.FNM_DIR || path_1.default.join(os_1.default.homedir(), "AppData", "Roaming", "fnm");
|
|
114
|
+
const fnmVersionsDirectory = path_1.default.join(fnmDirectory, "node-versions");
|
|
115
|
+
try {
|
|
116
|
+
for (const version of fs_1.default.readdirSync(fnmVersionsDirectory)) {
|
|
117
|
+
searchDirectories.push(path_1.default.join(fnmVersionsDirectory, version, "installation"));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// FNM is optional; ordinary PATH/npm resolution still applies.
|
|
122
|
+
}
|
|
123
|
+
for (const directory of [...new Set(searchDirectories)]) {
|
|
124
|
+
const javascriptLauncher = path_1.default.join(directory, "node_modules", "@openai", "codex", "bin", "codex.js");
|
|
125
|
+
if (fs_1.default.existsSync(javascriptLauncher)) {
|
|
126
|
+
return { command: process.execPath, prefixArgs: [javascriptLauncher] };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// npm exposes Codex as a batch shim on Windows. Avoid the extensionless
|
|
130
|
+
// POSIX shim, which child_process cannot execute on Windows.
|
|
131
|
+
return { command: "codex.cmd", prefixArgs: [] };
|
|
132
|
+
};
|
|
133
|
+
const normalizeCodexSnapshot = (accountResponse, rateResponse, usageResponse, capturedAt = new Date()) => {
|
|
134
|
+
const account = accountResponse?.account || accountResponse;
|
|
135
|
+
const usage = usageResponse?.usage || usageResponse?.totals || usageResponse || {};
|
|
136
|
+
const availableCount = rateResponse?.rateLimitResetCredits?.availableCount ?? account?.rateLimitResetCredits?.availableCount;
|
|
137
|
+
return {
|
|
138
|
+
source: "codex",
|
|
139
|
+
billingMode: "subscription",
|
|
140
|
+
plan: account?.planType || rateResponse?.planType,
|
|
141
|
+
windows: collectWindows(rateResponse),
|
|
142
|
+
resetCreditsAvailable: safeCount(availableCount),
|
|
143
|
+
inputTokens: safeCount(usage?.inputTokens),
|
|
144
|
+
outputTokens: safeCount(usage?.outputTokens),
|
|
145
|
+
cachedInputTokens: safeCount(usage?.cachedInputTokens),
|
|
146
|
+
requests: safeCount(usage?.requests),
|
|
147
|
+
capturedAt: capturedAt.toISOString(),
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
exports.normalizeCodexSnapshot = normalizeCodexSnapshot;
|
|
151
|
+
class CodexRpcClient {
|
|
152
|
+
child;
|
|
153
|
+
nextId = 1;
|
|
154
|
+
buffer = "";
|
|
155
|
+
pending = new Map();
|
|
156
|
+
constructor(child) {
|
|
157
|
+
this.child = child;
|
|
158
|
+
child.stdout.setEncoding("utf8");
|
|
159
|
+
child.stdout.on("data", (chunk) => this.receive(chunk));
|
|
160
|
+
child.on("exit", () => this.rejectAll(new Error("CODEX_APP_SERVER_EXITED")));
|
|
161
|
+
}
|
|
162
|
+
receive(chunk) {
|
|
163
|
+
this.buffer += chunk;
|
|
164
|
+
let newline = this.buffer.indexOf("\n");
|
|
165
|
+
while (newline >= 0) {
|
|
166
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
167
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
168
|
+
newline = this.buffer.indexOf("\n");
|
|
169
|
+
if (!line)
|
|
170
|
+
continue;
|
|
171
|
+
let message;
|
|
172
|
+
try {
|
|
173
|
+
message = JSON.parse(line);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (typeof message.id !== "number")
|
|
179
|
+
continue;
|
|
180
|
+
const request = this.pending.get(message.id);
|
|
181
|
+
if (!request)
|
|
182
|
+
continue;
|
|
183
|
+
clearTimeout(request.timer);
|
|
184
|
+
this.pending.delete(message.id);
|
|
185
|
+
if (message.error)
|
|
186
|
+
request.reject(new Error(message.error.message || "CODEX_RPC_ERROR"));
|
|
187
|
+
else
|
|
188
|
+
request.resolve(message.result);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
rejectAll(error) {
|
|
192
|
+
for (const request of this.pending.values()) {
|
|
193
|
+
clearTimeout(request.timer);
|
|
194
|
+
request.reject(error);
|
|
195
|
+
}
|
|
196
|
+
this.pending.clear();
|
|
197
|
+
}
|
|
198
|
+
request(method, params = {}, timeoutMs = 15_000) {
|
|
199
|
+
const id = this.nextId++;
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const timer = setTimeout(() => {
|
|
202
|
+
this.pending.delete(id);
|
|
203
|
+
reject(new Error(`CODEX_RPC_TIMEOUT:${method}`));
|
|
204
|
+
}, timeoutMs);
|
|
205
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
206
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
notify(method, params = {}) {
|
|
210
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const detectCodexVersion = () => {
|
|
214
|
+
const invocation = resolveCodexInvocation();
|
|
215
|
+
const result = (0, child_process_1.spawnSync)(invocation.command, [...invocation.prefixArgs, "--version"], {
|
|
216
|
+
encoding: "utf8",
|
|
217
|
+
windowsHide: true,
|
|
218
|
+
});
|
|
219
|
+
if (result.error || result.status !== 0)
|
|
220
|
+
return null;
|
|
221
|
+
return result.stdout.trim() || result.stderr.trim() || null;
|
|
222
|
+
};
|
|
223
|
+
exports.detectCodexVersion = detectCodexVersion;
|
|
224
|
+
const collectCodexUsage = async () => {
|
|
225
|
+
const version = (0, exports.detectCodexVersion)();
|
|
226
|
+
if (!version)
|
|
227
|
+
return { ok: false, code: "CODEX_NOT_INSTALLED", message: "Codex is not installed." };
|
|
228
|
+
const invocation = resolveCodexInvocation();
|
|
229
|
+
const child = (0, child_process_1.spawn)(invocation.command, [...invocation.prefixArgs, "app-server"], {
|
|
230
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
231
|
+
windowsHide: true,
|
|
232
|
+
});
|
|
233
|
+
let stderr = "";
|
|
234
|
+
child.stderr.setEncoding("utf8");
|
|
235
|
+
child.stderr.on("data", (chunk) => {
|
|
236
|
+
if (stderr.length < 1000)
|
|
237
|
+
stderr += chunk;
|
|
238
|
+
});
|
|
239
|
+
const rpc = new CodexRpcClient(child);
|
|
240
|
+
try {
|
|
241
|
+
await rpc.request("initialize", {
|
|
242
|
+
clientInfo: { name: "tickrmeter_ai_usage", title: "TickrMeter AI Usage", version: "0.1.0" },
|
|
243
|
+
capabilities: {},
|
|
244
|
+
});
|
|
245
|
+
rpc.notify("initialized");
|
|
246
|
+
const account = await rpc.request("account/read", { refreshToken: false });
|
|
247
|
+
if (!account?.account && !account?.authMode && !account?.planType) {
|
|
248
|
+
return { ok: false, code: "CODEX_NOT_LOGGED_IN", message: "Codex is installed but not signed in. Run: codex login", version };
|
|
249
|
+
}
|
|
250
|
+
const rateLimits = await rpc.request("account/rateLimits/read");
|
|
251
|
+
let usage;
|
|
252
|
+
try {
|
|
253
|
+
usage = await rpc.request("account/usage/read", {}, 8_000);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Usage activity is optional; subscription limits remain useful without it.
|
|
257
|
+
}
|
|
258
|
+
const snapshot = (0, exports.normalizeCodexSnapshot)(account, rateLimits, usage);
|
|
259
|
+
if (!snapshot.windows?.length) {
|
|
260
|
+
return { ok: false, code: "CODEX_RATE_LIMITS_UNAVAILABLE", message: "Codex did not return subscription limit windows.", version };
|
|
261
|
+
}
|
|
262
|
+
return { ok: true, snapshot, version };
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
const message = error instanceof Error ? error.message : "CODEX_APP_SERVER_FAILED";
|
|
266
|
+
const code = /login|account|unauth/i.test(message) ? "CODEX_NOT_LOGGED_IN" : "CODEX_APP_SERVER_FAILED";
|
|
267
|
+
return { ok: false, code, message: code === "CODEX_NOT_LOGGED_IN" ? "Run: codex login" : "Codex app-server could not be queried.", version };
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
try {
|
|
271
|
+
child.stdin.end();
|
|
272
|
+
child.kill();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Process may already have exited.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
exports.collectCodexUsage = collectCodexUsage;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.revokeBridge = exports.fetchBridgeStatus = exports.uploadSnapshots = exports.exchangePairingCode = void 0;
|
|
4
|
+
const apiUrl = (server, path) => `${server.replace(/\/$/, "")}/api${path}`;
|
|
5
|
+
const parseResponse = async (response) => {
|
|
6
|
+
const body = await response.json().catch(() => null);
|
|
7
|
+
if (!response.ok || body?.success === false)
|
|
8
|
+
throw new Error(body?.message || `HTTP_${response.status}`);
|
|
9
|
+
return body?.data ?? body;
|
|
10
|
+
};
|
|
11
|
+
const exchangePairingCode = async (server, code, cliVersion) => {
|
|
12
|
+
const response = await fetch(apiUrl(server, "/integrations/ai-usage/bridge/exchange"), {
|
|
13
|
+
method: "POST",
|
|
14
|
+
headers: { "Content-Type": "application/json" },
|
|
15
|
+
body: JSON.stringify({ code, cliVersion, deviceName: process.env.COMPUTERNAME || process.env.HOSTNAME || "Personal computer" }),
|
|
16
|
+
});
|
|
17
|
+
return parseResponse(response);
|
|
18
|
+
};
|
|
19
|
+
exports.exchangePairingCode = exchangePairingCode;
|
|
20
|
+
const uploadSnapshots = async (config, snapshots) => {
|
|
21
|
+
const response = await fetch(apiUrl(config.server, "/integrations/ai-usage/bridge/snapshot"), {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "Content-Type": "application/json", Authorization: `Bridge ${config.bridgeToken}` },
|
|
24
|
+
body: JSON.stringify({ snapshots }),
|
|
25
|
+
});
|
|
26
|
+
return parseResponse(response);
|
|
27
|
+
};
|
|
28
|
+
exports.uploadSnapshots = uploadSnapshots;
|
|
29
|
+
const fetchBridgeStatus = async (config) => {
|
|
30
|
+
const response = await fetch(apiUrl(config.server, "/integrations/ai-usage/bridge/status"), {
|
|
31
|
+
headers: { Authorization: `Bridge ${config.bridgeToken}` },
|
|
32
|
+
});
|
|
33
|
+
return parseResponse(response);
|
|
34
|
+
};
|
|
35
|
+
exports.fetchBridgeStatus = fetchBridgeStatus;
|
|
36
|
+
const revokeBridge = async (config) => {
|
|
37
|
+
const response = await fetch(apiUrl(config.server, "/integrations/ai-usage/bridge/current"), {
|
|
38
|
+
method: "DELETE",
|
|
39
|
+
headers: { Authorization: `Bridge ${config.bridgeToken}` },
|
|
40
|
+
});
|
|
41
|
+
return parseResponse(response);
|
|
42
|
+
};
|
|
43
|
+
exports.revokeBridge = revokeBridge;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.uninstallScheduler = exports.installScheduler = exports.buildCronLine = exports.buildSystemdTimer = exports.buildSystemdService = exports.buildLaunchAgentPlist = exports.buildWindowsTaskArgs = exports.getWindowsTaskCommand = exports.buildWindowsSyncScript = exports.getFixedSyncCommand = exports.CRON_MARKER = exports.SYSTEMD_NAME = exports.LAUNCH_AGENT_LABEL = exports.WINDOWS_TASK_NAME = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
11
|
+
const storage_1 = require("./storage");
|
|
12
|
+
exports.WINDOWS_TASK_NAME = "TickrMeter AI Usage Sync";
|
|
13
|
+
exports.LAUNCH_AGENT_LABEL = "com.tickrmeter.ai-usage";
|
|
14
|
+
exports.SYSTEMD_NAME = "tickrmeter-ai-usage";
|
|
15
|
+
exports.CRON_MARKER = "# TickrMeter AI Usage Sync";
|
|
16
|
+
const quoteShell = (value) => `"${value.replace(/"/g, '\\"')}"`;
|
|
17
|
+
const getFixedSyncCommand = (nodePath = process.execPath, cliPath = (0, storage_1.getInstalledCliPath)()) => `${quoteShell(nodePath)} ${quoteShell(cliPath)} sync`;
|
|
18
|
+
exports.getFixedSyncCommand = getFixedSyncCommand;
|
|
19
|
+
const quoteVbsString = (value) => `"${value.replace(/"/g, '""')}"`;
|
|
20
|
+
const windowsSyncScriptPath = () => path_1.default.join((0, storage_1.getDataDir)(), "sync-hidden.vbs");
|
|
21
|
+
const buildWindowsSyncScript = (nodePath = process.execPath, cliPath = (0, storage_1.getInstalledCliPath)()) => {
|
|
22
|
+
const command = `${quoteShell(nodePath)} ${quoteShell(cliPath)} sync`;
|
|
23
|
+
return [
|
|
24
|
+
'Set shell = CreateObject("WScript.Shell")',
|
|
25
|
+
`command = ${quoteVbsString(command)}`,
|
|
26
|
+
"exitCode = shell.Run(command, 0, True)",
|
|
27
|
+
"WScript.Quit exitCode",
|
|
28
|
+
"",
|
|
29
|
+
].join("\r\n");
|
|
30
|
+
};
|
|
31
|
+
exports.buildWindowsSyncScript = buildWindowsSyncScript;
|
|
32
|
+
const getWindowsTaskCommand = (scriptPath = windowsSyncScriptPath()) => {
|
|
33
|
+
const windowsDirectory = process.env.SystemRoot || "C:\\Windows";
|
|
34
|
+
const wscriptPath = path_1.default.join(windowsDirectory, "System32", "wscript.exe");
|
|
35
|
+
return `${quoteShell(wscriptPath)} //B //NoLogo ${quoteShell(scriptPath)}`;
|
|
36
|
+
};
|
|
37
|
+
exports.getWindowsTaskCommand = getWindowsTaskCommand;
|
|
38
|
+
const buildWindowsTaskArgs = (command = (0, exports.getWindowsTaskCommand)()) => [
|
|
39
|
+
"/Create",
|
|
40
|
+
"/TN",
|
|
41
|
+
exports.WINDOWS_TASK_NAME,
|
|
42
|
+
"/TR",
|
|
43
|
+
command,
|
|
44
|
+
"/SC",
|
|
45
|
+
"MINUTE",
|
|
46
|
+
"/MO",
|
|
47
|
+
"5",
|
|
48
|
+
"/F",
|
|
49
|
+
];
|
|
50
|
+
exports.buildWindowsTaskArgs = buildWindowsTaskArgs;
|
|
51
|
+
const buildLaunchAgentPlist = (nodePath = process.execPath, cliPath = (0, storage_1.getInstalledCliPath)()) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
52
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
53
|
+
<plist version="1.0"><dict>
|
|
54
|
+
<key>Label</key><string>${exports.LAUNCH_AGENT_LABEL}</string>
|
|
55
|
+
<key>ProgramArguments</key><array><string>${nodePath}</string><string>${cliPath}</string><string>sync</string></array>
|
|
56
|
+
<key>StartInterval</key><integer>300</integer>
|
|
57
|
+
<key>RunAtLoad</key><true/>
|
|
58
|
+
</dict></plist>
|
|
59
|
+
`;
|
|
60
|
+
exports.buildLaunchAgentPlist = buildLaunchAgentPlist;
|
|
61
|
+
const buildSystemdService = (nodePath = process.execPath, cliPath = (0, storage_1.getInstalledCliPath)()) => `[Unit]
|
|
62
|
+
Description=TickrMeter AI Usage sync
|
|
63
|
+
|
|
64
|
+
[Service]
|
|
65
|
+
Type=oneshot
|
|
66
|
+
ExecStart=${quoteShell(nodePath)} ${quoteShell(cliPath)} sync
|
|
67
|
+
`;
|
|
68
|
+
exports.buildSystemdService = buildSystemdService;
|
|
69
|
+
const buildSystemdTimer = () => `[Unit]
|
|
70
|
+
Description=Sync TickrMeter AI Usage every five minutes
|
|
71
|
+
|
|
72
|
+
[Timer]
|
|
73
|
+
OnBootSec=1min
|
|
74
|
+
OnUnitActiveSec=5min
|
|
75
|
+
Persistent=true
|
|
76
|
+
|
|
77
|
+
[Install]
|
|
78
|
+
WantedBy=timers.target
|
|
79
|
+
`;
|
|
80
|
+
exports.buildSystemdTimer = buildSystemdTimer;
|
|
81
|
+
const buildCronLine = (command = (0, exports.getFixedSyncCommand)()) => `*/5 * * * * ${command} >/dev/null 2>&1 ${exports.CRON_MARKER}`;
|
|
82
|
+
exports.buildCronLine = buildCronLine;
|
|
83
|
+
const run = (command, args, input) => {
|
|
84
|
+
const result = (0, child_process_1.spawnSync)(command, args, { encoding: "utf8", input, windowsHide: true });
|
|
85
|
+
return {
|
|
86
|
+
status: result.status,
|
|
87
|
+
stdout: result.stdout || "",
|
|
88
|
+
stderr: result.stderr || "",
|
|
89
|
+
error: result.error,
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const launchAgentPath = () => path_1.default.join(os_1.default.homedir(), "Library", "LaunchAgents", `${exports.LAUNCH_AGENT_LABEL}.plist`);
|
|
93
|
+
const systemdDirectory = () => path_1.default.join(os_1.default.homedir(), ".config", "systemd", "user");
|
|
94
|
+
const systemdServicePath = () => path_1.default.join(systemdDirectory(), `${exports.SYSTEMD_NAME}.service`);
|
|
95
|
+
const systemdTimerPath = () => path_1.default.join(systemdDirectory(), `${exports.SYSTEMD_NAME}.timer`);
|
|
96
|
+
const installCron = (runner = run) => {
|
|
97
|
+
const existingResult = runner("crontab", ["-l"]);
|
|
98
|
+
const existing = existingResult.status === 0 ? existingResult.stdout : "";
|
|
99
|
+
const retained = existing
|
|
100
|
+
.split(/\r?\n/)
|
|
101
|
+
.filter((line) => line && !line.includes(exports.CRON_MARKER));
|
|
102
|
+
retained.push((0, exports.buildCronLine)());
|
|
103
|
+
const installed = runner("crontab", ["-"], `${retained.join("\n")}\n`);
|
|
104
|
+
if (installed.status !== 0)
|
|
105
|
+
throw new Error("AI_USAGE_SCHEDULER_INSTALL_FAILED");
|
|
106
|
+
return "cron";
|
|
107
|
+
};
|
|
108
|
+
const installScheduler = (platform = process.platform, runner = run) => {
|
|
109
|
+
let kind;
|
|
110
|
+
if (platform === "win32") {
|
|
111
|
+
const scriptPath = windowsSyncScriptPath();
|
|
112
|
+
(0, storage_1.atomicWriteFile)(scriptPath, (0, exports.buildWindowsSyncScript)());
|
|
113
|
+
const result = runner("schtasks", (0, exports.buildWindowsTaskArgs)((0, exports.getWindowsTaskCommand)(scriptPath)));
|
|
114
|
+
if (result.status !== 0) {
|
|
115
|
+
fs_1.default.rmSync(scriptPath, { force: true });
|
|
116
|
+
throw new Error("AI_USAGE_SCHEDULER_INSTALL_FAILED");
|
|
117
|
+
}
|
|
118
|
+
kind = "windows-task";
|
|
119
|
+
}
|
|
120
|
+
else if (platform === "darwin") {
|
|
121
|
+
const file = launchAgentPath();
|
|
122
|
+
(0, storage_1.atomicWriteFile)(file, (0, exports.buildLaunchAgentPlist)(), 0o600);
|
|
123
|
+
runner("launchctl", ["unload", file]);
|
|
124
|
+
const result = runner("launchctl", ["load", file]);
|
|
125
|
+
if (result.status !== 0)
|
|
126
|
+
throw new Error("AI_USAGE_SCHEDULER_INSTALL_FAILED");
|
|
127
|
+
kind = "launch-agent";
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
fs_1.default.mkdirSync(systemdDirectory(), { recursive: true, mode: 0o700 });
|
|
131
|
+
(0, storage_1.atomicWriteFile)(systemdServicePath(), (0, exports.buildSystemdService)());
|
|
132
|
+
(0, storage_1.atomicWriteFile)(systemdTimerPath(), (0, exports.buildSystemdTimer)());
|
|
133
|
+
const reload = runner("systemctl", ["--user", "daemon-reload"]);
|
|
134
|
+
const enable = reload.status === 0 ? runner("systemctl", ["--user", "enable", "--now", `${exports.SYSTEMD_NAME}.timer`]) : reload;
|
|
135
|
+
if (enable.status === 0)
|
|
136
|
+
kind = "systemd-user";
|
|
137
|
+
else {
|
|
138
|
+
fs_1.default.rmSync(systemdServicePath(), { force: true });
|
|
139
|
+
fs_1.default.rmSync(systemdTimerPath(), { force: true });
|
|
140
|
+
kind = installCron(runner);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const state = (0, storage_1.readState)();
|
|
144
|
+
(0, storage_1.writeState)({ ...state, scheduler: { platform, kind, installedAt: new Date().toISOString() } });
|
|
145
|
+
return { kind };
|
|
146
|
+
};
|
|
147
|
+
exports.installScheduler = installScheduler;
|
|
148
|
+
const uninstallScheduler = (runner = run) => {
|
|
149
|
+
const state = (0, storage_1.readState)();
|
|
150
|
+
const kind = state.scheduler?.kind;
|
|
151
|
+
if (kind === "windows-task") {
|
|
152
|
+
runner("schtasks", ["/Delete", "/TN", exports.WINDOWS_TASK_NAME, "/F"]);
|
|
153
|
+
fs_1.default.rmSync(windowsSyncScriptPath(), { force: true });
|
|
154
|
+
}
|
|
155
|
+
if (kind === "launch-agent") {
|
|
156
|
+
runner("launchctl", ["unload", launchAgentPath()]);
|
|
157
|
+
fs_1.default.rmSync(launchAgentPath(), { force: true });
|
|
158
|
+
}
|
|
159
|
+
if (kind === "systemd-user") {
|
|
160
|
+
runner("systemctl", ["--user", "disable", "--now", `${exports.SYSTEMD_NAME}.timer`]);
|
|
161
|
+
fs_1.default.rmSync(systemdServicePath(), { force: true });
|
|
162
|
+
fs_1.default.rmSync(systemdTimerPath(), { force: true });
|
|
163
|
+
runner("systemctl", ["--user", "daemon-reload"]);
|
|
164
|
+
}
|
|
165
|
+
if (kind === "cron") {
|
|
166
|
+
const existing = runner("crontab", ["-l"]);
|
|
167
|
+
const source = existing.status === 0 ? existing.stdout : "";
|
|
168
|
+
const retained = source.split(/\r?\n/).filter((line) => line && !line.includes(exports.CRON_MARKER));
|
|
169
|
+
runner("crontab", ["-"], retained.length ? `${retained.join("\n")}\n` : "");
|
|
170
|
+
}
|
|
171
|
+
const { scheduler: _removed, ...rest } = state;
|
|
172
|
+
(0, storage_1.writeState)(rest);
|
|
173
|
+
return { removed: Boolean(kind) };
|
|
174
|
+
};
|
|
175
|
+
exports.uninstallScheduler = uninstallScheduler;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.removeLocalBridgeCredential = exports.installFixedCliCopy = exports.writeState = exports.readState = exports.writeConfig = exports.readConfig = exports.readJson = exports.atomicWriteJson = exports.atomicWriteFile = exports.ensureDataDir = exports.getInstalledStatusLinePath = exports.getInstalledCliPath = exports.getStatePath = exports.getClaudeCapturePath = exports.getConfigPath = exports.getDataDir = exports.CLI_VERSION = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
exports.CLI_VERSION = "0.1.0";
|
|
11
|
+
const getDataDir = () => process.env.TICKRMETER_AI_HOME || path_1.default.join(os_1.default.homedir(), ".tickrmeter-ai");
|
|
12
|
+
exports.getDataDir = getDataDir;
|
|
13
|
+
const getConfigPath = () => path_1.default.join((0, exports.getDataDir)(), "config.json");
|
|
14
|
+
exports.getConfigPath = getConfigPath;
|
|
15
|
+
const getClaudeCapturePath = () => path_1.default.join((0, exports.getDataDir)(), "claude-usage.json");
|
|
16
|
+
exports.getClaudeCapturePath = getClaudeCapturePath;
|
|
17
|
+
const getStatePath = () => path_1.default.join((0, exports.getDataDir)(), "state.json");
|
|
18
|
+
exports.getStatePath = getStatePath;
|
|
19
|
+
const getInstalledCliPath = () => path_1.default.join((0, exports.getDataDir)(), "app", "dist", "cli.js");
|
|
20
|
+
exports.getInstalledCliPath = getInstalledCliPath;
|
|
21
|
+
const getInstalledStatusLinePath = () => path_1.default.join((0, exports.getDataDir)(), "app", "dist", "claude-statusline.js");
|
|
22
|
+
exports.getInstalledStatusLinePath = getInstalledStatusLinePath;
|
|
23
|
+
const ensureDataDir = () => fs_1.default.mkdirSync((0, exports.getDataDir)(), { recursive: true, mode: 0o700 });
|
|
24
|
+
exports.ensureDataDir = ensureDataDir;
|
|
25
|
+
const atomicWriteFile = (file, contents, mode = 0o600) => {
|
|
26
|
+
(0, exports.ensureDataDir)();
|
|
27
|
+
fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true, mode: 0o700 });
|
|
28
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
29
|
+
fs_1.default.writeFileSync(temporary, contents, { mode });
|
|
30
|
+
fs_1.default.renameSync(temporary, file);
|
|
31
|
+
try {
|
|
32
|
+
fs_1.default.chmodSync(file, mode);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Windows ACLs are managed by the current user profile.
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
exports.atomicWriteFile = atomicWriteFile;
|
|
39
|
+
const atomicWriteJson = (file, value) => {
|
|
40
|
+
(0, exports.atomicWriteFile)(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
41
|
+
};
|
|
42
|
+
exports.atomicWriteJson = atomicWriteJson;
|
|
43
|
+
const readJson = (file) => {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs_1.default.readFileSync(file, "utf8"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
exports.readJson = readJson;
|
|
52
|
+
const readConfig = () => (0, exports.readJson)((0, exports.getConfigPath)());
|
|
53
|
+
exports.readConfig = readConfig;
|
|
54
|
+
const writeConfig = (config) => (0, exports.atomicWriteJson)((0, exports.getConfigPath)(), config);
|
|
55
|
+
exports.writeConfig = writeConfig;
|
|
56
|
+
const readState = () => (0, exports.readJson)((0, exports.getStatePath)()) || {};
|
|
57
|
+
exports.readState = readState;
|
|
58
|
+
const writeState = (state) => (0, exports.atomicWriteJson)((0, exports.getStatePath)(), state);
|
|
59
|
+
exports.writeState = writeState;
|
|
60
|
+
const installFixedCliCopy = () => {
|
|
61
|
+
const packageRoot = path_1.default.resolve(__dirname, "..");
|
|
62
|
+
const destination = path_1.default.join((0, exports.getDataDir)(), "app");
|
|
63
|
+
if (path_1.default.resolve(packageRoot) === path_1.default.resolve(destination))
|
|
64
|
+
return;
|
|
65
|
+
fs_1.default.rmSync(destination, { recursive: true, force: true });
|
|
66
|
+
fs_1.default.mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
67
|
+
fs_1.default.cpSync(path_1.default.join(packageRoot, "dist"), path_1.default.join(destination, "dist"), { recursive: true });
|
|
68
|
+
for (const file of ["package.json", "README.md"]) {
|
|
69
|
+
const source = path_1.default.join(packageRoot, file);
|
|
70
|
+
if (fs_1.default.existsSync(source))
|
|
71
|
+
fs_1.default.copyFileSync(source, path_1.default.join(destination, file));
|
|
72
|
+
}
|
|
73
|
+
return (0, exports.getInstalledCliPath)();
|
|
74
|
+
};
|
|
75
|
+
exports.installFixedCliCopy = installFixedCliCopy;
|
|
76
|
+
const removeLocalBridgeCredential = () => {
|
|
77
|
+
try {
|
|
78
|
+
fs_1.default.rmSync((0, exports.getConfigPath)(), { force: true });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Idempotent disconnect.
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
exports.removeLocalBridgeCredential = removeLocalBridgeCredential;
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getLocalStatus = exports.syncPersonalUsage = exports.collectPersonalUsage = void 0;
|
|
4
|
+
const claude_1 = require("./claude");
|
|
5
|
+
const codex_1 = require("./codex");
|
|
6
|
+
const http_1 = require("./http");
|
|
7
|
+
const storage_1 = require("./storage");
|
|
8
|
+
const collectPersonalUsage = async (codexCollector = codex_1.collectCodexUsage, claudeCollector = claude_1.getClaudeUsage) => {
|
|
9
|
+
const codexPromise = codexCollector().catch(() => ({ ok: false, code: "CODEX_APP_SERVER_FAILED", message: "Codex app-server could not be queried." }));
|
|
10
|
+
const claudePromise = Promise.resolve().then(claudeCollector).catch(() => ({ ok: false, code: "CLAUDE_NOT_AVAILABLE", message: "Claude usage capture could not be read." }));
|
|
11
|
+
return Promise.all([codexPromise, claudePromise]);
|
|
12
|
+
};
|
|
13
|
+
exports.collectPersonalUsage = collectPersonalUsage;
|
|
14
|
+
const syncPersonalUsage = async () => {
|
|
15
|
+
const config = (0, storage_1.readConfig)();
|
|
16
|
+
if (!config)
|
|
17
|
+
throw new Error("AI_USAGE_NOT_CONNECTED");
|
|
18
|
+
const providers = await (0, exports.collectPersonalUsage)();
|
|
19
|
+
const snapshots = providers.filter((result) => result.ok).map((result) => result.snapshot);
|
|
20
|
+
if (snapshots.length)
|
|
21
|
+
await (0, http_1.uploadSnapshots)(config, snapshots);
|
|
22
|
+
return { uploaded: snapshots.map((snapshot) => snapshot.source), providers };
|
|
23
|
+
};
|
|
24
|
+
exports.syncPersonalUsage = syncPersonalUsage;
|
|
25
|
+
const getLocalStatus = async () => {
|
|
26
|
+
const config = (0, storage_1.readConfig)();
|
|
27
|
+
if (!config)
|
|
28
|
+
return { connected: false, providers: await (0, exports.collectPersonalUsage)() };
|
|
29
|
+
let bridge = null;
|
|
30
|
+
try {
|
|
31
|
+
bridge = await (0, http_1.fetchBridgeStatus)(config);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
bridge = { status: "offline" };
|
|
35
|
+
}
|
|
36
|
+
return { connected: true, bridgeId: config.bridgeId, bridge, providers: await (0, exports.collectPersonalUsage)() };
|
|
37
|
+
};
|
|
38
|
+
exports.getLocalStatus = getLocalStatus;
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tickrmeter/ai-usage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Privacy-preserving TickrMeter personal AI usage bridge",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "commonjs",
|
|
10
|
+
"bin": {
|
|
11
|
+
"tickrmeter-ai": "dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist/*.js",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
22
|
+
"build": "npm run clean && tsc",
|
|
23
|
+
"test": "npm run build && node --test dist/tests/*.test.js"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^18.19.0",
|
|
27
|
+
"typescript": "^5.0.2"
|
|
28
|
+
}
|
|
29
|
+
}
|