@aaa-sentinel/ai-usage 0.1.1 → 0.2.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 +339 -124
- package/dist/cli.js +560 -149
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
|
-
import { homedir } from "node:os";
|
|
6
|
-
import { dirname as
|
|
5
|
+
import { homedir, userInfo } from "node:os";
|
|
6
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
|
|
@@ -40,6 +40,7 @@ function createLauncher(options) {
|
|
|
40
40
|
const command = formatCommand(args);
|
|
41
41
|
const uid = options.uid ?? 0;
|
|
42
42
|
const windowsCmd = join(scriptDir, "run.cmd");
|
|
43
|
+
const logonXml = join(scriptDir, "logon.xml");
|
|
43
44
|
return {
|
|
44
45
|
async install() {
|
|
45
46
|
if (platform === "win32") {
|
|
@@ -53,7 +54,21 @@ ${args.map(cmdArg).join(" ")}\r
|
|
|
53
54
|
try {
|
|
54
55
|
await run(exec, "schtasks", ["/Create", "/F", "/TN", TASK_HOURLY, "/SC", "HOURLY", "/ST", "00:00", "/IT", "/TR", tr]);
|
|
55
56
|
hourlyCreated = true;
|
|
56
|
-
|
|
57
|
+
const user = xmlEscape(options.windowsUser ?? "");
|
|
58
|
+
const xml = `<?xml version="1.0" encoding="UTF-16"?>\r
|
|
59
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">\r
|
|
60
|
+
<Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${user}</UserId></LogonTrigger></Triggers>\r
|
|
61
|
+
<Principals><Principal id="Author"><UserId>${user}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>\r
|
|
62
|
+
<Settings><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries></Settings>\r
|
|
63
|
+
<Actions Context="Author"><Exec><Command>${xmlEscape(windowsCmd)}</Command></Exec></Actions>\r
|
|
64
|
+
</Task>\r
|
|
65
|
+
`;
|
|
66
|
+
writeFileSync(logonXml, Buffer.from(`\uFEFF${xml}`, "utf16le"));
|
|
67
|
+
try {
|
|
68
|
+
await run(exec, "schtasks", ["/Create", "/F", "/TN", TASK_LOGON, "/XML", logonXml]);
|
|
69
|
+
} finally {
|
|
70
|
+
rmSync(logonXml, { force: true });
|
|
71
|
+
}
|
|
57
72
|
} catch (err) {
|
|
58
73
|
if (previousCmd === null && hourlyCreated) await exec("schtasks", ["/Delete", "/TN", TASK_HOURLY, "/F"]);
|
|
59
74
|
if (previousCmd === null) rmSync(windowsCmd, { force: true });
|
|
@@ -173,10 +188,137 @@ WantedBy=timers.target
|
|
|
173
188
|
};
|
|
174
189
|
}
|
|
175
190
|
|
|
191
|
+
// src/quota.ts
|
|
192
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
193
|
+
import { dirname } from "node:path";
|
|
194
|
+
var MAX_RESETS_PER_DAY = 48;
|
|
195
|
+
function num(value) {
|
|
196
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
197
|
+
}
|
|
198
|
+
function asRecord(value) {
|
|
199
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
200
|
+
}
|
|
201
|
+
function readQuotaState(path) {
|
|
202
|
+
if (!existsSync2(path)) return { days: {} };
|
|
203
|
+
try {
|
|
204
|
+
const root = asRecord(JSON.parse(readFileSync2(path, "utf8")));
|
|
205
|
+
const days = {};
|
|
206
|
+
for (const [day, raw] of Object.entries(asRecord(root?.["days"]) ?? {})) {
|
|
207
|
+
const entry = asRecord(raw);
|
|
208
|
+
if (!entry || !/^\d{4}-\d{2}-\d{2}$/.test(day)) continue;
|
|
209
|
+
const parsed = {};
|
|
210
|
+
const five = num(entry["fiveHourPeak"]);
|
|
211
|
+
const seven = num(entry["sevenDayPeak"]);
|
|
212
|
+
if (five !== void 0) parsed.fiveHourPeak = five;
|
|
213
|
+
if (seven !== void 0) parsed.sevenDayPeak = seven;
|
|
214
|
+
const resets = entry["fiveHourLimitResets"];
|
|
215
|
+
if (Array.isArray(resets)) {
|
|
216
|
+
const valid = resets.filter((end) => typeof end === "number" && Number.isInteger(end) && end > 0);
|
|
217
|
+
if (valid.length > 0) parsed.fiveHourLimitResets = valid.slice(-MAX_RESETS_PER_DAY);
|
|
218
|
+
}
|
|
219
|
+
days[day] = parsed;
|
|
220
|
+
}
|
|
221
|
+
const lastRaw = asRecord(root?.["last"]);
|
|
222
|
+
const state = { days };
|
|
223
|
+
if (lastRaw) {
|
|
224
|
+
const last = {};
|
|
225
|
+
const five = num(lastRaw["fiveHour"]);
|
|
226
|
+
const seven = num(lastRaw["sevenDay"]);
|
|
227
|
+
if (five !== void 0) last.fiveHour = five;
|
|
228
|
+
if (seven !== void 0) last.sevenDay = seven;
|
|
229
|
+
state.last = last;
|
|
230
|
+
}
|
|
231
|
+
return state;
|
|
232
|
+
} catch {
|
|
233
|
+
return { days: {} };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function writeQuotaState(path, state) {
|
|
237
|
+
mkdirSync2(dirname(path), { recursive: true, mode: 448 });
|
|
238
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
239
|
+
writeFileSync2(temp, `${JSON.stringify(state)}
|
|
240
|
+
`, { mode: 384 });
|
|
241
|
+
renameSync(temp, path);
|
|
242
|
+
}
|
|
243
|
+
function recordStatusSample(state, input, now) {
|
|
244
|
+
const limits = asRecord(asRecord(input)?.["rate_limits"]);
|
|
245
|
+
if (!limits) return false;
|
|
246
|
+
const fiveHour = asRecord(limits["five_hour"]);
|
|
247
|
+
const sevenDay = asRecord(limits["seven_day"]);
|
|
248
|
+
const five = num(fiveHour?.["used_percentage"]);
|
|
249
|
+
const seven = num(sevenDay?.["used_percentage"]);
|
|
250
|
+
if (five === void 0 && seven === void 0) return false;
|
|
251
|
+
const day = now.toISOString().slice(0, 10);
|
|
252
|
+
const entry = state.days[day] ?? {};
|
|
253
|
+
const before = JSON.stringify(entry);
|
|
254
|
+
if (five !== void 0) entry.fiveHourPeak = Math.max(entry.fiveHourPeak ?? 0, five);
|
|
255
|
+
if (seven !== void 0) entry.sevenDayPeak = Math.max(entry.sevenDayPeak ?? 0, seven);
|
|
256
|
+
const resetsAt = num(fiveHour?.["resets_at"]);
|
|
257
|
+
if (five !== void 0 && five >= 100 && resetsAt !== void 0 && Number.isInteger(resetsAt)) {
|
|
258
|
+
const resets = new Set(entry.fiveHourLimitResets ?? []);
|
|
259
|
+
resets.add(resetsAt);
|
|
260
|
+
entry.fiveHourLimitResets = [...resets].slice(-MAX_RESETS_PER_DAY);
|
|
261
|
+
}
|
|
262
|
+
state.days[day] = entry;
|
|
263
|
+
const lastBefore = JSON.stringify(state.last ?? {});
|
|
264
|
+
state.last = {
|
|
265
|
+
...five !== void 0 ? { fiveHour: five } : state.last?.fiveHour !== void 0 ? { fiveHour: state.last.fiveHour } : {},
|
|
266
|
+
...seven !== void 0 ? { sevenDay: seven } : state.last?.sevenDay !== void 0 ? { sevenDay: state.last.sevenDay } : {}
|
|
267
|
+
};
|
|
268
|
+
return JSON.stringify(entry) !== before || JSON.stringify(state.last) !== lastBefore;
|
|
269
|
+
}
|
|
270
|
+
function defaultStatusText(state) {
|
|
271
|
+
const parts = [];
|
|
272
|
+
if (state.last?.fiveHour !== void 0) parts.push(`5 h ${Math.round(state.last.fiveHour)} %`);
|
|
273
|
+
if (state.last?.sevenDay !== void 0) parts.push(`semana ${Math.round(state.last.sevenDay)} %`);
|
|
274
|
+
return parts.join(" \xB7 ");
|
|
275
|
+
}
|
|
276
|
+
function statusLineCommand(args, platform) {
|
|
277
|
+
return args.map((arg) => platform === "win32" ? arg.replaceAll("\\", "/") : arg).map((arg) => `"${arg.replaceAll('"', '\\"')}"`).join(" ");
|
|
278
|
+
}
|
|
279
|
+
function parseSettings(text) {
|
|
280
|
+
if (!text.trim()) return {};
|
|
281
|
+
try {
|
|
282
|
+
return asRecord(JSON.parse(text));
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function statusLineOf(root) {
|
|
288
|
+
const raw = asRecord(root["statusLine"]);
|
|
289
|
+
if (!raw || typeof raw["command"] !== "string") return void 0;
|
|
290
|
+
const setting = { type: typeof raw["type"] === "string" ? raw["type"] : "command", command: raw["command"] };
|
|
291
|
+
if (typeof raw["padding"] === "number") setting.padding = raw["padding"];
|
|
292
|
+
return setting;
|
|
293
|
+
}
|
|
294
|
+
function mergeClaudeStatusLine(existing, command) {
|
|
295
|
+
const root = parseSettings(existing);
|
|
296
|
+
if (!root) return { status: "unreadable" };
|
|
297
|
+
const previous = statusLineOf(root);
|
|
298
|
+
if (previous?.command === command) return { status: "already" };
|
|
299
|
+
const next = { ...root, statusLine: { type: "command", command, ...previous?.padding !== void 0 ? { padding: previous.padding } : {} } };
|
|
300
|
+
return { status: "installed", text: `${JSON.stringify(next, null, 2)}
|
|
301
|
+
`, ...previous ? { previous } : {} };
|
|
302
|
+
}
|
|
303
|
+
function hasClaudeStatusLine(existing, command) {
|
|
304
|
+
const root = parseSettings(existing);
|
|
305
|
+
return root ? statusLineOf(root)?.command === command : false;
|
|
306
|
+
}
|
|
307
|
+
function removeClaudeStatusLine(existing, command, previous) {
|
|
308
|
+
const root = parseSettings(existing);
|
|
309
|
+
if (!root || statusLineOf(root)?.command !== command) return existing;
|
|
310
|
+
const next = { ...root };
|
|
311
|
+
if (previous) next["statusLine"] = { ...previous };
|
|
312
|
+
else delete next["statusLine"];
|
|
313
|
+
return `${JSON.stringify(next, null, 2)}
|
|
314
|
+
`;
|
|
315
|
+
}
|
|
316
|
+
|
|
176
317
|
// src/session.ts
|
|
318
|
+
import { spawnSync } from "node:child_process";
|
|
177
319
|
import { randomBytes } from "node:crypto";
|
|
178
|
-
import { chmodSync, existsSync as
|
|
179
|
-
import { dirname, join as join4 } from "node:path";
|
|
320
|
+
import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync4, rmSync as rmSync2, unlinkSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
321
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
180
322
|
|
|
181
323
|
// src/cursor.ts
|
|
182
324
|
var MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
@@ -239,19 +381,19 @@ ${event.model ?? ""}`;
|
|
|
239
381
|
}
|
|
240
382
|
return byDay;
|
|
241
383
|
}
|
|
242
|
-
function
|
|
384
|
+
function asRecord2(value) {
|
|
243
385
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
244
386
|
return value;
|
|
245
387
|
}
|
|
246
388
|
function mergeCursorHook(existing, command) {
|
|
247
389
|
let parsed = {};
|
|
248
390
|
if (existing.trim()) parsed = JSON.parse(existing);
|
|
249
|
-
const root =
|
|
250
|
-
const hooks =
|
|
391
|
+
const root = asRecord2(parsed) ?? {};
|
|
392
|
+
const hooks = asRecord2(root["hooks"]) ?? {};
|
|
251
393
|
const stopRaw = hooks["stop"];
|
|
252
|
-
const stop = Array.isArray(stopRaw) ? stopRaw.filter((entry) =>
|
|
394
|
+
const stop = Array.isArray(stopRaw) ? stopRaw.filter((entry) => asRecord2(entry)?.["command"] !== command) : [];
|
|
253
395
|
const kept = stop.flatMap((entry) => {
|
|
254
|
-
const record =
|
|
396
|
+
const record = asRecord2(entry);
|
|
255
397
|
if (!record || typeof record["command"] !== "string") return [];
|
|
256
398
|
return [record];
|
|
257
399
|
});
|
|
@@ -260,11 +402,11 @@ function mergeCursorHook(existing, command) {
|
|
|
260
402
|
}
|
|
261
403
|
function removeCursorHook(existing, command) {
|
|
262
404
|
if (!existing.trim()) return existing;
|
|
263
|
-
const root =
|
|
405
|
+
const root = asRecord2(JSON.parse(existing));
|
|
264
406
|
if (!root) return existing;
|
|
265
|
-
const hooks =
|
|
407
|
+
const hooks = asRecord2(root["hooks"]);
|
|
266
408
|
if (!hooks || !Array.isArray(hooks["stop"])) return existing;
|
|
267
|
-
const stop = hooks["stop"].filter((entry) =>
|
|
409
|
+
const stop = hooks["stop"].filter((entry) => asRecord2(entry)?.["command"] !== command);
|
|
268
410
|
if (stop.length === 0) {
|
|
269
411
|
const rest = {};
|
|
270
412
|
for (const [key, value] of Object.entries(hooks)) {
|
|
@@ -559,7 +701,7 @@ function readClaudeIncrement(dir, previous) {
|
|
|
559
701
|
}
|
|
560
702
|
|
|
561
703
|
// src/codex.ts
|
|
562
|
-
import { closeSync as closeSync2, openSync as openSync2, readdirSync as readdirSync2, readFileSync as
|
|
704
|
+
import { closeSync as closeSync2, openSync as openSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, readSync as readSync2, statSync as statSync2 } from "node:fs";
|
|
563
705
|
import { join as join3, relative as relative2 } from "node:path";
|
|
564
706
|
var MODEL_PATTERN3 = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
565
707
|
var TOKEN_MAX3 = 1e12;
|
|
@@ -757,7 +899,7 @@ function readChunk2(path, offset) {
|
|
|
757
899
|
}
|
|
758
900
|
function readCodexAuth(home) {
|
|
759
901
|
try {
|
|
760
|
-
return classifyCodexAuth(
|
|
902
|
+
return classifyCodexAuth(readFileSync3(join3(home, "auth.json"), "utf8"));
|
|
761
903
|
} catch {
|
|
762
904
|
return "unknown";
|
|
763
905
|
}
|
|
@@ -891,12 +1033,39 @@ async function ghJson(gh, path) {
|
|
|
891
1033
|
return { auth: false, payload: null };
|
|
892
1034
|
}
|
|
893
1035
|
}
|
|
894
|
-
|
|
1036
|
+
function creditsFromCopilotUsage(payload) {
|
|
1037
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return void 0;
|
|
1038
|
+
const items = payload["usageItems"];
|
|
1039
|
+
if (!Array.isArray(items)) return void 0;
|
|
1040
|
+
let total = 0;
|
|
1041
|
+
for (const item of items) {
|
|
1042
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
1043
|
+
const record = item;
|
|
1044
|
+
const unit = typeof record["unitType"] === "string" ? record["unitType"].toLowerCase() : "";
|
|
1045
|
+
const quantity = typeof record["grossQuantity"] === "number" && Number.isFinite(record["grossQuantity"]) && record["grossQuantity"] >= 0 ? record["grossQuantity"] : void 0;
|
|
1046
|
+
if (unit.includes("credit") && quantity !== void 0) total += quantity;
|
|
1047
|
+
}
|
|
1048
|
+
return total;
|
|
1049
|
+
}
|
|
1050
|
+
async function copilotLogin(gh) {
|
|
895
1051
|
const user = await ghJson(gh, "user");
|
|
896
1052
|
if (user.auth) return { status: "auth" };
|
|
897
1053
|
if (!user.payload) return { status: "unavailable" };
|
|
898
1054
|
const login2 = user.payload && typeof user.payload === "object" && !Array.isArray(user.payload) ? user.payload["login"] : void 0;
|
|
899
1055
|
if (typeof login2 !== "string" || !/^[A-Za-z0-9-]{1,39}$/.test(login2)) return { status: "empty" };
|
|
1056
|
+
return { status: "ok", login: login2 };
|
|
1057
|
+
}
|
|
1058
|
+
async function readCopilotMonthCredits(gh, day) {
|
|
1059
|
+
const user = await copilotLogin(gh);
|
|
1060
|
+
if (user.status !== "ok") return void 0;
|
|
1061
|
+
const [year, month] = day.split("-");
|
|
1062
|
+
const usage = await ghJson(gh, `/users/${user.login}/settings/billing/ai_credit/usage?year=${year}&month=${Number(month)}`);
|
|
1063
|
+
return usage.payload ? creditsFromCopilotUsage(usage.payload) : void 0;
|
|
1064
|
+
}
|
|
1065
|
+
async function readCopilotDay(gh, day) {
|
|
1066
|
+
const user = await copilotLogin(gh);
|
|
1067
|
+
if (user.status !== "ok") return { status: user.status };
|
|
1068
|
+
const login2 = user.login;
|
|
900
1069
|
const [year, month, date] = day.split("-");
|
|
901
1070
|
const query = `year=${year}&month=${Number(month)}&day=${Number(date)}`;
|
|
902
1071
|
const base = `/users/${login2}/settings/billing`;
|
|
@@ -960,7 +1129,8 @@ async function postTelemetry(options) {
|
|
|
960
1129
|
turns: row.turns,
|
|
961
1130
|
tokensKnown: row.tokensKnown,
|
|
962
1131
|
...row.model ? { model: row.model } : {}
|
|
963
|
-
}))
|
|
1132
|
+
})),
|
|
1133
|
+
...options.quota ? { quota: options.quota } : {}
|
|
964
1134
|
})
|
|
965
1135
|
});
|
|
966
1136
|
let code;
|
|
@@ -980,47 +1150,82 @@ var EXIT_REPORT = 4;
|
|
|
980
1150
|
var EXIT_COPILOT = 5;
|
|
981
1151
|
var MAX_ROWS = 40;
|
|
982
1152
|
var TOKEN_PATTERN = /^sau_[A-Za-z0-9_-]{20,200}$/;
|
|
1153
|
+
function parseStatusLineConfig(value) {
|
|
1154
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
1155
|
+
const record = value;
|
|
1156
|
+
if (record["state"] !== "installed" && record["state"] !== "declined") return void 0;
|
|
1157
|
+
const config = { state: record["state"] };
|
|
1158
|
+
const previous = record["previous"];
|
|
1159
|
+
if (previous && typeof previous === "object" && !Array.isArray(previous)) {
|
|
1160
|
+
const raw = previous;
|
|
1161
|
+
if (typeof raw["command"] === "string") {
|
|
1162
|
+
config.previous = {
|
|
1163
|
+
type: typeof raw["type"] === "string" ? raw["type"] : "command",
|
|
1164
|
+
command: raw["command"],
|
|
1165
|
+
...typeof raw["padding"] === "number" ? { padding: raw["padding"] } : {}
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return config;
|
|
1170
|
+
}
|
|
983
1171
|
var DEVICE_PATTERN = /^[a-f0-9]{16}$/;
|
|
984
1172
|
function newDevice() {
|
|
985
1173
|
return randomBytes(8).toString("hex");
|
|
986
1174
|
}
|
|
987
|
-
function
|
|
988
|
-
if (
|
|
1175
|
+
function parseConnection(record) {
|
|
1176
|
+
if (typeof record["token"] !== "string") return null;
|
|
1177
|
+
const connection = { token: record["token"] };
|
|
1178
|
+
if (isUsageSource(record["source"])) connection.source = record["source"];
|
|
1179
|
+
if (typeof record["addedAt"] === "string") connection.addedAt = record["addedAt"];
|
|
1180
|
+
if (typeof record["lastAcceptedDay"] === "string") connection.lastAcceptedDay = record["lastAcceptedDay"];
|
|
1181
|
+
if (record["claude"]) connection.claude = parseClaudeState(record["claude"]);
|
|
1182
|
+
if (record["codex"]) connection.codex = parseCodexState(record["codex"]);
|
|
1183
|
+
return connection;
|
|
1184
|
+
}
|
|
1185
|
+
function readConfig(path) {
|
|
1186
|
+
if (!existsSync3(path)) return { connections: [] };
|
|
989
1187
|
try {
|
|
990
|
-
const parsed = JSON.parse(
|
|
991
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
1188
|
+
const parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
1189
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { connections: [] };
|
|
992
1190
|
const record = parsed;
|
|
993
|
-
const config = {};
|
|
994
|
-
if (typeof record["token"] === "string") config.token = record["token"];
|
|
1191
|
+
const config = { connections: [] };
|
|
995
1192
|
if (typeof record["device"] === "string" && DEVICE_PATTERN.test(record["device"])) config.device = record["device"];
|
|
996
|
-
if (
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1193
|
+
if (Array.isArray(record["connections"])) {
|
|
1194
|
+
for (const entry of record["connections"]) {
|
|
1195
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
1196
|
+
const connection = parseConnection(entry);
|
|
1197
|
+
if (connection) config.connections.push(connection);
|
|
1198
|
+
}
|
|
1199
|
+
} else {
|
|
1200
|
+
const legacy = parseConnection(record);
|
|
1201
|
+
if (legacy) config.connections.push(legacy);
|
|
1202
|
+
}
|
|
1203
|
+
const statusLine2 = parseStatusLineConfig(record["claudeStatusLine"]);
|
|
1204
|
+
if (statusLine2) config.claudeStatusLine = statusLine2;
|
|
1000
1205
|
return config;
|
|
1001
1206
|
} catch {
|
|
1002
|
-
return {};
|
|
1207
|
+
return { connections: [] };
|
|
1003
1208
|
}
|
|
1004
1209
|
}
|
|
1005
|
-
function
|
|
1006
|
-
|
|
1007
|
-
|
|
1210
|
+
function writeConfig(path, value) {
|
|
1211
|
+
mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
|
|
1212
|
+
writeFileSync3(path, `${JSON.stringify({ version: 2, ...value })}
|
|
1008
1213
|
`, { mode: 384 });
|
|
1009
1214
|
chmodSync(path, 384);
|
|
1010
1215
|
}
|
|
1011
1216
|
function restoreFile(path, previous) {
|
|
1012
1217
|
if (previous === null) {
|
|
1013
|
-
if (
|
|
1218
|
+
if (existsSync3(path)) unlinkSync(path);
|
|
1014
1219
|
return;
|
|
1015
1220
|
}
|
|
1016
|
-
|
|
1221
|
+
writeFileSync3(path, previous);
|
|
1017
1222
|
}
|
|
1018
1223
|
function readSpool(dir) {
|
|
1019
|
-
if (!
|
|
1224
|
+
if (!existsSync3(dir)) return [];
|
|
1020
1225
|
return readdirSync3(dir).filter((name) => name.endsWith(".json")).map((name) => {
|
|
1021
1226
|
const path = join4(dir, name);
|
|
1022
1227
|
try {
|
|
1023
|
-
return { path, event: readSpoolEvent(JSON.parse(
|
|
1228
|
+
return { path, event: readSpoolEvent(JSON.parse(readFileSync4(path, "utf8"))) };
|
|
1024
1229
|
} catch {
|
|
1025
1230
|
return { path, event: null };
|
|
1026
1231
|
}
|
|
@@ -1032,27 +1237,46 @@ function utcDay(date) {
|
|
|
1032
1237
|
function clearSpool(dir) {
|
|
1033
1238
|
rmSync2(dir, { recursive: true, force: true });
|
|
1034
1239
|
}
|
|
1240
|
+
function forgetLocalState(paths, source) {
|
|
1241
|
+
if (source === "cursor") clearSpool(paths.spoolPath);
|
|
1242
|
+
if (source === "claude_code" && paths.quotaPath) rmSync2(paths.quotaPath, { force: true });
|
|
1243
|
+
}
|
|
1035
1244
|
async function login(options) {
|
|
1036
|
-
const token = options.token.replace(
|
|
1245
|
+
const token = options.token.replace(/^/, "").trim();
|
|
1037
1246
|
if (!TOKEN_PATTERN.test(token)) {
|
|
1038
1247
|
return { code: EXIT_USAGE, message: "El token no tiene el formato de un alta de uso." };
|
|
1039
1248
|
}
|
|
1040
|
-
const previousConfig =
|
|
1041
|
-
const previousHooks =
|
|
1042
|
-
const config =
|
|
1043
|
-
const
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1249
|
+
const previousConfig = existsSync3(options.paths.configPath) ? readFileSync4(options.paths.configPath, "utf8") : null;
|
|
1250
|
+
const previousHooks = existsSync3(options.paths.hooksPath) ? readFileSync4(options.paths.hooksPath, "utf8") : null;
|
|
1251
|
+
const config = readConfig(options.paths.configPath);
|
|
1252
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1253
|
+
const existing = config.connections.find((connection) => connection.token === token);
|
|
1254
|
+
let source = existing?.source;
|
|
1255
|
+
let replaced = [];
|
|
1256
|
+
if (!existing) {
|
|
1257
|
+
try {
|
|
1258
|
+
source = await options.probe?.(token);
|
|
1259
|
+
} catch {
|
|
1260
|
+
source = void 0;
|
|
1261
|
+
}
|
|
1262
|
+
const since = utcDay(now);
|
|
1263
|
+
const added = {
|
|
1264
|
+
token,
|
|
1265
|
+
addedAt: now.toISOString(),
|
|
1266
|
+
claude: emptyClaudeState(since),
|
|
1267
|
+
codex: emptyCodexState(since),
|
|
1268
|
+
...source ? { source } : {}
|
|
1269
|
+
};
|
|
1270
|
+
replaced = source ? config.connections.filter((connection) => connection.source === source) : [];
|
|
1271
|
+
config.connections = [...config.connections.filter((connection) => !replaced.includes(connection)), added];
|
|
1272
|
+
}
|
|
1273
|
+
config.device = config.device ?? newDevice();
|
|
1274
|
+
writeConfig(options.paths.configPath, config);
|
|
1051
1275
|
try {
|
|
1052
|
-
|
|
1053
|
-
|
|
1276
|
+
mkdirSync3(dirname2(options.paths.hooksPath), { recursive: true });
|
|
1277
|
+
writeFileSync3(options.paths.hooksPath, mergeCursorHook(previousHooks ?? "", options.hookCommand));
|
|
1054
1278
|
await options.launcher.install();
|
|
1055
|
-
if (
|
|
1279
|
+
if (replaced.length > 0) forgetLocalState(options.paths, source);
|
|
1056
1280
|
} catch (err) {
|
|
1057
1281
|
restoreFile(options.paths.configPath, previousConfig);
|
|
1058
1282
|
restoreFile(options.paths.hooksPath, previousHooks);
|
|
@@ -1062,72 +1286,108 @@ async function login(options) {
|
|
|
1062
1286
|
message: launcherFailed ? `No se pudo registrar el arranque. No se ha dejado el token instalado. ${err.message}` : `No se pudo instalar el hook de Cursor. No se ha dejado el token instalado. ${err.message}`
|
|
1063
1287
|
};
|
|
1064
1288
|
}
|
|
1065
|
-
|
|
1289
|
+
const tools = config.connections.map((connection) => connection.source ?? "sin comprobar").join(", ");
|
|
1290
|
+
const replacedNote = replaced.length > 0 ? ` Sustituye a la conexi\xF3n anterior de ${source}.` : "";
|
|
1291
|
+
return {
|
|
1292
|
+
code: 0,
|
|
1293
|
+
message: `Agente instalado. El arranque subir\xE1 el uso; no hace falta lanzarlo en cada sesi\xF3n.${replacedNote} Este ordenador informa de: ${tools}.`
|
|
1294
|
+
};
|
|
1066
1295
|
}
|
|
1067
1296
|
async function logout(options) {
|
|
1297
|
+
const config = readConfig(options.paths.configPath);
|
|
1298
|
+
const leaving = options.source ? config.connections.filter((connection) => connection.source === options.source) : config.connections;
|
|
1299
|
+
if (options.source && leaving.length === 0) {
|
|
1300
|
+
return { code: EXIT_USAGE, message: `Este ordenador no informa de ${options.source}.` };
|
|
1301
|
+
}
|
|
1302
|
+
const staying = config.connections.filter((connection) => !leaving.includes(connection));
|
|
1068
1303
|
let launcherError;
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1304
|
+
if (staying.length === 0) {
|
|
1305
|
+
try {
|
|
1306
|
+
await options.launcher.uninstall();
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
launcherError = err;
|
|
1309
|
+
}
|
|
1073
1310
|
}
|
|
1074
1311
|
let hookError = false;
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1312
|
+
const keepsCursor = staying.some((connection) => connection.source === "cursor" || !connection.source);
|
|
1313
|
+
if (!keepsCursor) {
|
|
1314
|
+
if (existsSync3(options.paths.hooksPath)) {
|
|
1315
|
+
try {
|
|
1316
|
+
const current = readFileSync4(options.paths.hooksPath, "utf8");
|
|
1317
|
+
writeFileSync3(options.paths.hooksPath, removeCursorHook(current, options.hookCommand));
|
|
1318
|
+
} catch {
|
|
1319
|
+
hookError = true;
|
|
1320
|
+
}
|
|
1081
1321
|
}
|
|
1322
|
+
clearSpool(options.paths.spoolPath);
|
|
1082
1323
|
}
|
|
1083
|
-
|
|
1084
|
-
const
|
|
1085
|
-
if (
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1324
|
+
const next = { ...config.device ? { device: config.device } : {}, connections: staying };
|
|
1325
|
+
const keepsClaude = staying.some((connection) => connection.source === "claude_code" || !connection.source);
|
|
1326
|
+
if (keepsClaude) {
|
|
1327
|
+
if (config.claudeStatusLine) next.claudeStatusLine = config.claudeStatusLine;
|
|
1328
|
+
} else {
|
|
1329
|
+
if (config.claudeStatusLine?.state === "installed") {
|
|
1330
|
+
restoreClaudeStatusLine(options.paths, options.statusLineCommand, config.claudeStatusLine.previous);
|
|
1331
|
+
}
|
|
1332
|
+
if (options.paths.quotaPath) rmSync2(options.paths.quotaPath, { force: true });
|
|
1089
1333
|
}
|
|
1334
|
+
if (existsSync3(options.paths.configPath) || staying.length > 0) writeConfig(options.paths.configPath, next);
|
|
1090
1335
|
if (launcherError) {
|
|
1091
1336
|
return { code: EXIT_LAUNCHER, message: `No se pudo quitar el arranque. ${launcherError.message}` };
|
|
1092
1337
|
}
|
|
1093
1338
|
if (hookError) {
|
|
1094
1339
|
return { code: EXIT_USAGE, message: "El arranque se quit\xF3, pero el hook de Cursor no: su fichero no es JSON." };
|
|
1095
1340
|
}
|
|
1341
|
+
if (staying.length > 0) {
|
|
1342
|
+
return { code: 0, message: `Conexi\xF3n retirada. Este ordenador sigue informando de: ${staying.map((connection) => connection.source ?? "sin comprobar").join(", ")}.` };
|
|
1343
|
+
}
|
|
1096
1344
|
return { code: 0, message: "Agente retirado." };
|
|
1097
1345
|
}
|
|
1098
1346
|
function dropCursorHook(paths, hookCommand) {
|
|
1099
|
-
if (!hookCommand || !
|
|
1347
|
+
if (!hookCommand || !existsSync3(paths.hooksPath)) return;
|
|
1100
1348
|
try {
|
|
1101
|
-
const current =
|
|
1349
|
+
const current = readFileSync4(paths.hooksPath, "utf8");
|
|
1102
1350
|
const next = removeCursorHook(current, hookCommand);
|
|
1103
|
-
if (next !== current)
|
|
1351
|
+
if (next !== current) writeFileSync3(paths.hooksPath, next);
|
|
1104
1352
|
} catch {
|
|
1105
1353
|
}
|
|
1106
1354
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
const today = utcDay(options.now ?? /* @__PURE__ */ new Date());
|
|
1115
|
-
const probe = await fetchUsageSource({ baseUrl, token, fetchImpl: options.fetchImpl });
|
|
1116
|
-
if (probe.status === 401) {
|
|
1117
|
-
return { code: EXIT_REPORT, message: "El servidor no reconoce el token. Hay que repetir el login con un token nuevo." };
|
|
1355
|
+
function restoreClaudeStatusLine(paths, command, previous) {
|
|
1356
|
+
if (!command || !paths.claudeSettingsPath || !existsSync3(paths.claudeSettingsPath)) return;
|
|
1357
|
+
try {
|
|
1358
|
+
const current = readFileSync4(paths.claudeSettingsPath, "utf8");
|
|
1359
|
+
const next = removeClaudeStatusLine(current, command, previous);
|
|
1360
|
+
if (next !== current) writeFileSync3(paths.claudeSettingsPath, next);
|
|
1361
|
+
} catch {
|
|
1118
1362
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1363
|
+
}
|
|
1364
|
+
function ensureClaudeStatusLine(paths, command, current) {
|
|
1365
|
+
const settingsPath = paths.claudeSettingsPath;
|
|
1366
|
+
if (!command || !settingsPath || current?.state === "declined") return current;
|
|
1367
|
+
try {
|
|
1368
|
+
const text = existsSync3(settingsPath) ? readFileSync4(settingsPath, "utf8") : "";
|
|
1369
|
+
if (current?.state === "installed") {
|
|
1370
|
+
return hasClaudeStatusLine(text, command) ? current : { state: "declined", ...current.previous ? { previous: current.previous } : {} };
|
|
1371
|
+
}
|
|
1372
|
+
const merged = mergeClaudeStatusLine(text, command);
|
|
1373
|
+
if (merged.status === "unreadable") return current;
|
|
1374
|
+
if (merged.status === "already") return { state: "installed" };
|
|
1375
|
+
mkdirSync3(dirname2(settingsPath), { recursive: true });
|
|
1376
|
+
writeFileSync3(settingsPath, merged.text);
|
|
1377
|
+
return { state: "installed", ...merged.previous ? { previous: merged.previous } : {} };
|
|
1378
|
+
} catch {
|
|
1379
|
+
return current;
|
|
1122
1380
|
}
|
|
1123
|
-
|
|
1381
|
+
}
|
|
1382
|
+
async function reportConnection(options, connection, source, context) {
|
|
1383
|
+
const { baseUrl, device, today } = context;
|
|
1384
|
+
const token = connection.token;
|
|
1124
1385
|
const accepted = /* @__PURE__ */ new Set();
|
|
1125
1386
|
const outcome = { error: void 0, mismatch: false, acceptedAny: false };
|
|
1126
|
-
let lastAccepted =
|
|
1127
|
-
let
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
if (rows.length === 0) return "skip";
|
|
1387
|
+
let lastAccepted = connection.lastAcceptedDay;
|
|
1388
|
+
let statusLine2 = context.statusLine;
|
|
1389
|
+
const send = async (day, rows, kept, quota) => {
|
|
1390
|
+
if (rows.length === 0 && !quota) return "skip";
|
|
1131
1391
|
if (rows.length > MAX_ROWS) {
|
|
1132
1392
|
outcome.error = `El d\xEDa ${day} tiene m\xE1s de ${MAX_ROWS} modelos y no se ha subido.`;
|
|
1133
1393
|
return "stop";
|
|
@@ -1139,7 +1399,8 @@ async function report(options) {
|
|
|
1139
1399
|
rows,
|
|
1140
1400
|
fetchImpl: options.fetchImpl,
|
|
1141
1401
|
source,
|
|
1142
|
-
...source === "github_copilot" ? {} : { device }
|
|
1402
|
+
...source === "github_copilot" ? {} : { device },
|
|
1403
|
+
...quota ? { quota } : {}
|
|
1143
1404
|
});
|
|
1144
1405
|
if (result.ok) {
|
|
1145
1406
|
outcome.acceptedAny = true;
|
|
@@ -1159,24 +1420,32 @@ async function report(options) {
|
|
|
1159
1420
|
let copilotUnavailable = false;
|
|
1160
1421
|
let codexAuth;
|
|
1161
1422
|
if (source === "cursor") {
|
|
1162
|
-
const byDay = aggregateByDay(
|
|
1423
|
+
const byDay = aggregateByDay(context.spool.flatMap((file) => file.event ? [file.event] : []));
|
|
1163
1424
|
for (const day of [...byDay.keys()].sort()) {
|
|
1164
1425
|
if (await send(day, byDay.get(day) ?? [], " El spool de ese d\xEDa sigue en el port\xE1til.") === "stop") break;
|
|
1165
1426
|
}
|
|
1166
1427
|
} else if (source === "claude_code") {
|
|
1428
|
+
statusLine2 = ensureClaudeStatusLine(options.paths, options.statusLineCommand, statusLine2);
|
|
1167
1429
|
const claudeDir = options.claudeProjectsDir;
|
|
1168
|
-
const ready = claudeDir &&
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
if (
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1430
|
+
const ready = claudeDir && existsSync3(claudeDir) ? readClaudeIncrement(claudeDir, connection.claude ?? emptyClaudeState(today)) : null;
|
|
1431
|
+
const quotaDays = options.paths.quotaPath ? readQuotaState(options.paths.quotaPath).days : {};
|
|
1432
|
+
const days = [.../* @__PURE__ */ new Set([...ready?.dirtyDays ?? [], ...Object.keys(quotaDays)])].sort();
|
|
1433
|
+
for (const day of days) {
|
|
1434
|
+
const rows = ready ? rowsForDay(ready.state.days, day) : [];
|
|
1435
|
+
if (await send(day, rows, " La transcripci\xF3n sigue en el port\xE1til.", quotaDays[day]) === "stop") break;
|
|
1436
|
+
}
|
|
1437
|
+
if (ready && !outcome.error) {
|
|
1438
|
+
connection.claude = ready.state;
|
|
1439
|
+
if (ready.caughtUp) pruneClaudeDays(connection.claude, previousUtcDay(today));
|
|
1440
|
+
}
|
|
1441
|
+
if (options.paths.quotaPath && accepted.size > 0) {
|
|
1442
|
+
const fresh = readQuotaState(options.paths.quotaPath);
|
|
1443
|
+
for (const day of Object.keys(fresh.days)) if (day < today && accepted.has(day)) delete fresh.days[day];
|
|
1444
|
+
writeQuotaState(options.paths.quotaPath, fresh);
|
|
1177
1445
|
}
|
|
1178
1446
|
} else if (source === "github_copilot") {
|
|
1179
1447
|
if (options.gh) {
|
|
1448
|
+
const credits = await readCopilotMonthCredits(options.gh, today);
|
|
1180
1449
|
for (const day of [previousUtcDay(today), today]) {
|
|
1181
1450
|
const read = await readCopilotDay(options.gh, day);
|
|
1182
1451
|
if (read.status === "auth") {
|
|
@@ -1187,78 +1456,183 @@ async function report(options) {
|
|
|
1187
1456
|
copilotUnavailable = true;
|
|
1188
1457
|
break;
|
|
1189
1458
|
}
|
|
1190
|
-
|
|
1191
|
-
if (
|
|
1459
|
+
const quota = day === today && credits !== void 0 ? { monthAiCredits: credits } : void 0;
|
|
1460
|
+
if (read.status !== "rows" && !quota) continue;
|
|
1461
|
+
if (await send(day, read.status === "rows" ? read.rows : [], "", quota) === "stop") break;
|
|
1192
1462
|
}
|
|
1193
1463
|
}
|
|
1194
1464
|
} else {
|
|
1195
1465
|
const codexHome2 = options.codexHome;
|
|
1196
|
-
if (codexHome2 &&
|
|
1466
|
+
if (codexHome2 && existsSync3(codexHome2)) {
|
|
1197
1467
|
const auth = readCodexAuth(codexHome2);
|
|
1198
1468
|
const sessions = join4(codexHome2, "sessions");
|
|
1199
1469
|
if (auth !== "chatgpt") codexAuth = auth;
|
|
1200
|
-
else if (
|
|
1201
|
-
const ready = readCodexIncrement(sessions,
|
|
1470
|
+
else if (existsSync3(sessions)) {
|
|
1471
|
+
const ready = readCodexIncrement(sessions, connection.codex ?? emptyCodexState(today));
|
|
1202
1472
|
for (const day of ready.dirtyDays) {
|
|
1203
1473
|
if (await send(day, rowsForCodexDay(ready.state.days, day), " La sesi\xF3n sigue en el port\xE1til.") === "stop") break;
|
|
1204
1474
|
}
|
|
1205
1475
|
if (!outcome.error) {
|
|
1206
|
-
|
|
1476
|
+
connection.codex = ready.state;
|
|
1207
1477
|
if (ready.caughtUp) {
|
|
1208
1478
|
const before = previousUtcDay(today);
|
|
1209
|
-
for (const day of Object.keys(
|
|
1210
|
-
if (day < before) delete
|
|
1479
|
+
for (const day of Object.keys(connection.codex.days)) {
|
|
1480
|
+
if (day < before) delete connection.codex.days[day];
|
|
1211
1481
|
}
|
|
1212
1482
|
}
|
|
1213
1483
|
}
|
|
1214
1484
|
}
|
|
1215
1485
|
}
|
|
1216
1486
|
}
|
|
1217
|
-
|
|
1218
|
-
if (
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
if (
|
|
1228
|
-
|
|
1229
|
-
if (outcome.error) return { code: EXIT_REPORT, message: outcome.error };
|
|
1230
|
-
if (outcome.mismatch) {
|
|
1231
|
-
return { code: 0, message: "El uso de esta herramienta no corresponde a esta conexi\xF3n y no se ha subido." };
|
|
1232
|
-
}
|
|
1487
|
+
if (lastAccepted) connection.lastAcceptedDay = lastAccepted;
|
|
1488
|
+
if (!outcome.mismatch) connection.source = source;
|
|
1489
|
+
const run2 = (result) => ({
|
|
1490
|
+
connection,
|
|
1491
|
+
source,
|
|
1492
|
+
result,
|
|
1493
|
+
mismatch: outcome.mismatch,
|
|
1494
|
+
accepted,
|
|
1495
|
+
...statusLine2 ? { statusLine: statusLine2 } : {}
|
|
1496
|
+
});
|
|
1497
|
+
if (outcome.error) return run2({ code: EXIT_REPORT, message: outcome.error });
|
|
1498
|
+
if (outcome.mismatch) return run2({ code: 0, message: "El uso de esta herramienta no corresponde a esta conexi\xF3n y no se ha subido." });
|
|
1233
1499
|
if (copilotAuth && !outcome.acceptedAny) {
|
|
1234
|
-
return { code: EXIT_COPILOT, message: "gh no tiene permiso para leer el uso de Copilot. No se ha escrito una fila vac\xEDa." };
|
|
1500
|
+
return run2({ code: EXIT_COPILOT, message: "gh no tiene permiso para leer el uso de Copilot. No se ha escrito una fila vac\xEDa." });
|
|
1235
1501
|
}
|
|
1236
1502
|
if (copilotUnavailable) {
|
|
1237
|
-
return { code: EXIT_COPILOT, message: "No se pudo usar gh. Hace falta GitHub CLI con gh auth login en la cuenta de Copilot." };
|
|
1503
|
+
return run2({ code: EXIT_COPILOT, message: "No se pudo usar gh. Hace falta GitHub CLI con gh auth login en la cuenta de Copilot." });
|
|
1238
1504
|
}
|
|
1239
1505
|
if (!outcome.acceptedAny) {
|
|
1240
|
-
if (codexAuth === "api_key") {
|
|
1241
|
-
|
|
1506
|
+
if (codexAuth === "api_key") return run2({ code: 0, message: "Codex usa una API key. Ese gasto no se atribuye a la cuenta individual." });
|
|
1507
|
+
if (codexAuth === "unknown") return run2({ code: 0, message: "No se pudo confirmar que Codex use la suscripci\xF3n de ChatGPT. No se ha subido nada." });
|
|
1508
|
+
return run2({ code: 0, message: "No hay uso nuevo que subir." });
|
|
1509
|
+
}
|
|
1510
|
+
return run2({ code: 0, message: `Uso aceptado hasta ${lastAccepted ?? "hoy"}.` });
|
|
1511
|
+
}
|
|
1512
|
+
async function report(options) {
|
|
1513
|
+
const config = readConfig(options.paths.configPath);
|
|
1514
|
+
if (config.connections.length === 0) return { code: EXIT_USAGE, message: "No hay token. Hay que hacer login en esta conexi\xF3n." };
|
|
1515
|
+
const device = config.device ?? newDevice();
|
|
1516
|
+
const baseUrl = resolveUsageBaseUrl(options.baseUrl);
|
|
1517
|
+
if (!baseUrl) return { code: EXIT_USAGE, message: "La URL del API tiene que ser https." };
|
|
1518
|
+
const today = utcDay(options.now ?? /* @__PURE__ */ new Date());
|
|
1519
|
+
const resolved = [];
|
|
1520
|
+
for (const connection of config.connections) {
|
|
1521
|
+
const probe = await fetchUsageSource({ baseUrl, token: connection.token, fetchImpl: options.fetchImpl });
|
|
1522
|
+
if (probe.status === 401) {
|
|
1523
|
+
resolved.push({
|
|
1524
|
+
connection,
|
|
1525
|
+
...connection.source ? { source: connection.source } : {},
|
|
1526
|
+
failure: { code: EXIT_REPORT, message: "El servidor no reconoce el token. Hay que repetir el login con un token nuevo." }
|
|
1527
|
+
});
|
|
1528
|
+
continue;
|
|
1242
1529
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1530
|
+
const source = probe.source ?? connection.source;
|
|
1531
|
+
resolved.push(
|
|
1532
|
+
source ? { connection, source } : { connection, failure: { code: EXIT_REPORT, message: `No se pudo saber qu\xE9 herramienta informa esta conexi\xF3n (${probe.status}). Se reintentar\xE1.` } }
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
const kept = [];
|
|
1536
|
+
const dropped = [];
|
|
1537
|
+
for (let index = resolved.length - 1; index >= 0; index--) {
|
|
1538
|
+
const entry = resolved[index];
|
|
1539
|
+
if (entry.source && kept.some((other) => !other.failure && other.source === entry.source)) dropped.push(entry);
|
|
1540
|
+
else kept.unshift(entry);
|
|
1541
|
+
}
|
|
1542
|
+
let spool = readSpool(options.paths.spoolPath);
|
|
1543
|
+
for (const entry of dropped) {
|
|
1544
|
+
if (entry.source !== "cursor") continue;
|
|
1545
|
+
const winner = kept.find((other) => other.source === "cursor")?.connection.addedAt;
|
|
1546
|
+
for (const file of spool) {
|
|
1547
|
+
if (!winner || !file.event || file.event.at < winner) unlinkSync(file.path);
|
|
1245
1548
|
}
|
|
1246
|
-
|
|
1549
|
+
spool = readSpool(options.paths.spoolPath);
|
|
1247
1550
|
}
|
|
1248
|
-
|
|
1551
|
+
const runs = [];
|
|
1552
|
+
const failures = [];
|
|
1553
|
+
let statusLine2 = config.claudeStatusLine;
|
|
1554
|
+
for (const entry of kept) {
|
|
1555
|
+
if (entry.failure || !entry.source) {
|
|
1556
|
+
failures.push({ ...entry.source ? { source: entry.source } : {}, result: entry.failure });
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
const run2 = await reportConnection(options, entry.connection, entry.source, {
|
|
1560
|
+
baseUrl,
|
|
1561
|
+
device,
|
|
1562
|
+
today,
|
|
1563
|
+
spool,
|
|
1564
|
+
...statusLine2 ? { statusLine: statusLine2 } : {}
|
|
1565
|
+
});
|
|
1566
|
+
if (entry.source === "claude_code") statusLine2 = run2.statusLine;
|
|
1567
|
+
runs.push(run2);
|
|
1568
|
+
}
|
|
1569
|
+
const pending = kept.some((entry) => !entry.source);
|
|
1570
|
+
const cursorRun = runs.find((run2) => run2.source === "cursor" && !run2.mismatch);
|
|
1571
|
+
const keepsCursor = pending || kept.some((entry) => entry.source === "cursor" && (entry.failure || !runs.find((run2) => run2.connection === entry.connection)?.mismatch));
|
|
1572
|
+
if (!keepsCursor) dropCursorHook(options.paths, options.hookCommand);
|
|
1573
|
+
for (const file of spool) {
|
|
1574
|
+
const day = file.event?.at.slice(0, 10);
|
|
1575
|
+
if (!keepsCursor || cursorRun && day && day < today && cursorRun.accepted.has(day)) unlinkSync(file.path);
|
|
1576
|
+
}
|
|
1577
|
+
const keepsClaude = pending || kept.some((entry) => entry.source === "claude_code" && (entry.failure || !runs.find((run2) => run2.connection === entry.connection)?.mismatch));
|
|
1578
|
+
if (!keepsClaude && statusLine2?.state === "installed") {
|
|
1579
|
+
restoreClaudeStatusLine(options.paths, options.statusLineCommand, statusLine2.previous);
|
|
1580
|
+
statusLine2 = void 0;
|
|
1581
|
+
}
|
|
1582
|
+
const next = { device, connections: kept.map((entry) => entry.connection) };
|
|
1583
|
+
if (statusLine2) next.claudeStatusLine = statusLine2;
|
|
1584
|
+
writeConfig(options.paths.configPath, next);
|
|
1585
|
+
const all = [
|
|
1586
|
+
...dropped.map((entry) => ({ source: entry.source, result: { code: 0, message: "Sustituida por un login m\xE1s reciente de la misma herramienta." } })),
|
|
1587
|
+
...failures,
|
|
1588
|
+
...runs.map((run2) => ({ source: run2.source, result: run2.result }))
|
|
1589
|
+
];
|
|
1590
|
+
if (all.length === 1) return all[0].result;
|
|
1591
|
+
return {
|
|
1592
|
+
code: all.find((entry) => entry.result.code !== 0)?.result.code ?? 0,
|
|
1593
|
+
message: all.map((entry) => `${entry.source ?? "sin comprobar"}: ${entry.result.message}`).join("\n")
|
|
1594
|
+
};
|
|
1249
1595
|
}
|
|
1250
1596
|
function previousUtcDay(day) {
|
|
1251
1597
|
return new Date(Date.parse(`${day}T00:00:00Z`) - 864e5).toISOString().slice(0, 10);
|
|
1252
1598
|
}
|
|
1599
|
+
var runPreviousWithShell = (command, input) => {
|
|
1600
|
+
const result = spawnSync(command, { shell: process.env["SHELL"] || true, input, encoding: "utf8", timeout: 5e3, windowsHide: true });
|
|
1601
|
+
return result.status === 0 && typeof result.stdout === "string" ? result.stdout : null;
|
|
1602
|
+
};
|
|
1603
|
+
function statusLine(options) {
|
|
1604
|
+
let parsed = null;
|
|
1605
|
+
try {
|
|
1606
|
+
parsed = JSON.parse(options.input);
|
|
1607
|
+
} catch {
|
|
1608
|
+
parsed = null;
|
|
1609
|
+
}
|
|
1610
|
+
let state = { days: {} };
|
|
1611
|
+
if (options.paths.quotaPath) {
|
|
1612
|
+
try {
|
|
1613
|
+
state = readQuotaState(options.paths.quotaPath);
|
|
1614
|
+
if (parsed && recordStatusSample(state, parsed, options.now)) writeQuotaState(options.paths.quotaPath, state);
|
|
1615
|
+
} catch {
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
const previous = readConfig(options.paths.configPath).claudeStatusLine?.previous;
|
|
1619
|
+
if (previous) {
|
|
1620
|
+
const output = (options.runPrevious ?? runPreviousWithShell)(previous.command, options.input);
|
|
1621
|
+
if (output !== null) return output;
|
|
1622
|
+
}
|
|
1623
|
+
return defaultStatusText(state);
|
|
1624
|
+
}
|
|
1253
1625
|
async function status(options) {
|
|
1254
|
-
const config =
|
|
1626
|
+
const config = readConfig(options.paths.configPath);
|
|
1255
1627
|
const installed = await options.launcher.installed();
|
|
1256
|
-
const lines = [
|
|
1257
|
-
|
|
1258
|
-
`
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1628
|
+
const lines = [`agente: ${config.connections.length > 0 ? "instalado" : "ausente"}`, `arranque: ${installed ? "s\xED" : "no"}`];
|
|
1629
|
+
for (const connection of config.connections) {
|
|
1630
|
+
lines.push(`herramienta: ${connection.source ?? "sin comprobar"}`, `\xFAltimo d\xEDa aceptado: ${connection.lastAcceptedDay ?? "ninguno"}`);
|
|
1631
|
+
if (connection.source === "claude_code") {
|
|
1632
|
+
const state = config.claudeStatusLine?.state;
|
|
1633
|
+
lines.push(`l\xEDnea de estado de Claude Code: ${state === "installed" ? "puesta" : state === "declined" ? "retirada por ti, no se vuelve a poner" : "no"}`);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1262
1636
|
return { code: 0, message: lines.join("\n") };
|
|
1263
1637
|
}
|
|
1264
1638
|
|
|
@@ -1274,14 +1648,24 @@ function nodeExec(file, args) {
|
|
|
1274
1648
|
})
|
|
1275
1649
|
);
|
|
1276
1650
|
}
|
|
1651
|
+
function claudeConfigDir() {
|
|
1652
|
+
return process.env["CLAUDE_CONFIG_DIR"] ?? join5(homedir(), ".claude");
|
|
1653
|
+
}
|
|
1277
1654
|
function pathsFromEnv() {
|
|
1278
1655
|
const home = process.env["SENTINEL_USAGE_HOME"] ?? join5(homedir(), ".config", "aaa-sentinel");
|
|
1279
1656
|
return {
|
|
1280
1657
|
configPath: join5(home, "usage.json"),
|
|
1281
1658
|
spoolPath: join5(home, "spool"),
|
|
1282
|
-
hooksPath: process.env["SENTINEL_USAGE_CURSOR_HOOKS"] ?? join5(homedir(), ".cursor", "hooks.json")
|
|
1659
|
+
hooksPath: process.env["SENTINEL_USAGE_CURSOR_HOOKS"] ?? join5(homedir(), ".cursor", "hooks.json"),
|
|
1660
|
+
quotaPath: join5(home, "claude-quota.json"),
|
|
1661
|
+
claudeSettingsPath: process.env["SENTINEL_USAGE_CLAUDE_SETTINGS"] ?? join5(claudeConfigDir(), "settings.json")
|
|
1283
1662
|
};
|
|
1284
1663
|
}
|
|
1664
|
+
function statusLineArgs() {
|
|
1665
|
+
if (process.env["SENTINEL_USAGE_NO_STATUSLINE"] === "1") return null;
|
|
1666
|
+
const cliJs = fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
1667
|
+
return [process.execPath, cliJs, "statusline"];
|
|
1668
|
+
}
|
|
1285
1669
|
function codexHome() {
|
|
1286
1670
|
return process.env["SENTINEL_USAGE_CODEX_HOME"] || process.env["CODEX_HOME"] || join5(homedir(), ".codex");
|
|
1287
1671
|
}
|
|
@@ -1297,7 +1681,7 @@ function ghRun(args) {
|
|
|
1297
1681
|
}
|
|
1298
1682
|
function claudeProjectsDir() {
|
|
1299
1683
|
if (process.env["SENTINEL_USAGE_CLAUDE_DIR"]) return process.env["SENTINEL_USAGE_CLAUDE_DIR"];
|
|
1300
|
-
return join5(
|
|
1684
|
+
return join5(claudeConfigDir(), "projects");
|
|
1301
1685
|
}
|
|
1302
1686
|
function hookArgs(spoolPath) {
|
|
1303
1687
|
const hookJs = fileURLToPath(new URL("./hook.js", import.meta.url));
|
|
@@ -1310,7 +1694,13 @@ function reportArgs() {
|
|
|
1310
1694
|
async function main() {
|
|
1311
1695
|
const [command, tokenArg] = [process.argv[2], process.argv[3]];
|
|
1312
1696
|
const paths = pathsFromEnv();
|
|
1697
|
+
if (command === "statusline") {
|
|
1698
|
+
process.stdout.write(statusLine({ input: await readStdin(), paths, now: /* @__PURE__ */ new Date() }));
|
|
1699
|
+
return 0;
|
|
1700
|
+
}
|
|
1313
1701
|
const hookCommand = formatCommand(hookArgs(paths.spoolPath));
|
|
1702
|
+
const statusArgs = statusLineArgs();
|
|
1703
|
+
const statusLineCommand2 = statusArgs ? statusLineCommand(statusArgs, process.platform) : void 0;
|
|
1314
1704
|
const uid = process.getuid?.();
|
|
1315
1705
|
const launcher = createLauncher({
|
|
1316
1706
|
platform: process.platform,
|
|
@@ -1318,8 +1708,9 @@ async function main() {
|
|
|
1318
1708
|
// El hook solo escribe el spool. Si la tarea lo relanza, el informe no sale del portátil.
|
|
1319
1709
|
args: reportArgs(),
|
|
1320
1710
|
homeDir: homedir(),
|
|
1321
|
-
scriptDir:
|
|
1322
|
-
...uid !== void 0 ? { uid } : {}
|
|
1711
|
+
scriptDir: dirname3(paths.configPath),
|
|
1712
|
+
...uid !== void 0 ? { uid } : {},
|
|
1713
|
+
windowsUser: process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${userInfo().username}` : userInfo().username
|
|
1323
1714
|
});
|
|
1324
1715
|
if (command === "login") {
|
|
1325
1716
|
if (tokenArg) {
|
|
@@ -1331,17 +1722,28 @@ async function main() {
|
|
|
1331
1722
|
return 2;
|
|
1332
1723
|
}
|
|
1333
1724
|
const token = (await readStdin()).trim();
|
|
1334
|
-
const
|
|
1725
|
+
const probe = sourceProbe();
|
|
1726
|
+
const result = await login({ token, paths, launcher, hookCommand, ...probe ? { probe } : {} });
|
|
1335
1727
|
console.log(result.message);
|
|
1336
1728
|
return result.code;
|
|
1337
1729
|
}
|
|
1338
1730
|
if (command === "logout") {
|
|
1339
|
-
|
|
1731
|
+
if (tokenArg && !isUsageSource(tokenArg)) {
|
|
1732
|
+
console.log("Herramienta desconocida. Usa cursor, claude_code, github_copilot o codex, o nada para retirarlas todas.");
|
|
1733
|
+
return 2;
|
|
1734
|
+
}
|
|
1735
|
+
const result = await logout({
|
|
1736
|
+
paths,
|
|
1737
|
+
launcher,
|
|
1738
|
+
hookCommand,
|
|
1739
|
+
...statusLineCommand2 ? { statusLineCommand: statusLineCommand2 } : {},
|
|
1740
|
+
...tokenArg && isUsageSource(tokenArg) ? { source: tokenArg } : {}
|
|
1741
|
+
});
|
|
1340
1742
|
console.log(result.message);
|
|
1341
1743
|
return result.code;
|
|
1342
1744
|
}
|
|
1343
1745
|
if (command === "report") {
|
|
1344
|
-
const baseUrl =
|
|
1746
|
+
const baseUrl = usageBaseUrl();
|
|
1345
1747
|
const result = await report({
|
|
1346
1748
|
paths,
|
|
1347
1749
|
baseUrl,
|
|
@@ -1349,7 +1751,8 @@ async function main() {
|
|
|
1349
1751
|
claudeProjectsDir: claudeProjectsDir(),
|
|
1350
1752
|
codexHome: codexHome(),
|
|
1351
1753
|
gh: ghRun,
|
|
1352
|
-
hookCommand
|
|
1754
|
+
hookCommand,
|
|
1755
|
+
...statusLineCommand2 ? { statusLineCommand: statusLineCommand2 } : {}
|
|
1353
1756
|
});
|
|
1354
1757
|
console.log(result.message);
|
|
1355
1758
|
return result.code;
|
|
@@ -1359,9 +1762,17 @@ async function main() {
|
|
|
1359
1762
|
console.log(result.message);
|
|
1360
1763
|
return result.code;
|
|
1361
1764
|
}
|
|
1362
|
-
console.log("Uso: sentinel-usage login|logout|report|status");
|
|
1765
|
+
console.log("Uso: sentinel-usage login|logout [herramienta]|report|status");
|
|
1363
1766
|
return 2;
|
|
1364
1767
|
}
|
|
1768
|
+
function usageBaseUrl() {
|
|
1769
|
+
return process.env["SENTINEL_USAGE_BASE_URL"] ?? DEFAULT_BASE_URL;
|
|
1770
|
+
}
|
|
1771
|
+
function sourceProbe() {
|
|
1772
|
+
const baseUrl = resolveUsageBaseUrl(usageBaseUrl());
|
|
1773
|
+
if (!baseUrl) return void 0;
|
|
1774
|
+
return async (token) => (await fetchUsageSource({ baseUrl, token, fetchImpl: fetch })).source;
|
|
1775
|
+
}
|
|
1365
1776
|
function readStdin() {
|
|
1366
1777
|
return new Promise((resolve, reject) => {
|
|
1367
1778
|
const chunks = [];
|