agent-dealer 1.2.1 → 1.2.3
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/bundle/server/dist/adapters/linear-inbox.js +9 -5
- package/bundle/server/dist/capacity/adapter.js +2 -0
- package/bundle/server/dist/capacity/claude-events.js +45 -10
- package/bundle/server/dist/capacity/claude-events.test.js +7 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +788 -0
- package/bundle/server/dist/capacity/claude-local-cache.test.js +520 -0
- package/bundle/server/dist/capacity/codex-app-server.js +100 -10
- package/bundle/server/dist/capacity/codex-app-server.test.js +195 -2
- package/bundle/server/dist/capacity/cursor-individual-credentials.js +425 -0
- package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +369 -0
- package/bundle/server/dist/capacity/cursor-individual.js +1037 -0
- package/bundle/server/dist/capacity/cursor-individual.test.js +1081 -0
- package/bundle/server/dist/capacity/muse-host.js +748 -0
- package/bundle/server/dist/capacity/muse-host.test.js +480 -0
- package/bundle/server/dist/capacity/muse-lifecycle.test.js +97 -0
- package/bundle/server/dist/capacity/muse.js +389 -91
- package/bundle/server/dist/capacity/muse.test.js +100 -61
- package/bundle/server/dist/capacity/service.js +1 -0
- package/bundle/server/dist/coordinator/muse-spawn.js +143 -1
- package/bundle/server/dist/db/index.js +30 -0
- package/bundle/server/dist/db/schema.sql +27 -0
- package/bundle/server/dist/index.js +17 -2
- package/bundle/server/dist/repository/cursor-individual-billing.js +84 -0
- package/bundle/server/dist/repository/repository-mappings.js +46 -0
- package/bundle/server/dist/repository/repository-mappings.test.js +83 -0
- package/bundle/server/dist/repository/runtime-capacity.js +8 -3
- package/bundle/server/dist/repository/runtime-capacity.test.js +4 -0
- package/bundle/server/dist/routes/cursor-individual-billing.test.js +80 -0
- package/bundle/server/dist/routes/index.js +58 -9
- package/bundle/server/dist/routes/intake-linear.test.js +55 -1
- package/bundle/server/dist/routes/repository-mappings.js +22 -0
- package/bundle/server/dist/routes/repository-mappings.test.js +110 -0
- package/bundle/server/dist/routes/runtime-capacity.test.js +84 -8
- package/bundle/server/dist/runners/muse-code-jsonl.js +7 -1
- package/bundle/server/dist/runners/muse-serve-session.js +313 -0
- package/bundle/server/dist/runners/muse-serve-session.test.js +325 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/codex-YZSpL-SB.png +0 -0
- package/bundle/server/static-ui/assets/index-CYqRh_-S.css +1 -0
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +60 -0
- package/bundle/server/static-ui/assets/muse-CtdcC6ve.svg +33 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +6 -6
- package/bundle/shared/dist/linear-intake.d.ts +71 -0
- package/bundle/shared/dist/linear-intake.js +90 -0
- package/bundle/shared/dist/linear-intake.test.js +80 -1
- package/bundle/shared/dist/runtime-capacity.d.ts +89 -0
- package/bundle/shared/dist/runtime-capacity.js +60 -0
- package/bundle/shared/dist/runtime-capacity.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +57 -0
- package/dist/doctor.js +211 -0
- package/dist/doctor.test.d.ts +1 -0
- package/dist/doctor.test.js +246 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-ChnI0F02.css +0 -1
- package/bundle/server/static-ui/assets/index-CyB1573Q.js +0 -60
|
@@ -34,6 +34,16 @@ export const CapacityUnavailableReason = z.enum([
|
|
|
34
34
|
"unparsable",
|
|
35
35
|
"stale",
|
|
36
36
|
]);
|
|
37
|
+
/**
|
|
38
|
+
* Which account-wide critical window this is, set once by the adapter that
|
|
39
|
+
* knows the provider's own identity for it (e.g. Codex's `rateLimits`
|
|
40
|
+
* aggregate, or whichever detailed limit pair it collapsed into) — never
|
|
41
|
+
* re-derived downstream from a window key, label, or duration, none of
|
|
42
|
+
* which can disambiguate multiple same-duration buckets a provider reports.
|
|
43
|
+
* Null means "not the account-wide pair" (a non-critical extra bucket, or a
|
|
44
|
+
* provider/window this contract has no opinion on).
|
|
45
|
+
*/
|
|
46
|
+
export const CapacityCriticalRole = z.enum(["five_hour", "weekly"]);
|
|
37
47
|
export const CapacityWindowSnapshot = z.object({
|
|
38
48
|
/** Stable per-runtime key for this window (e.g. `weekly_all_models`). */
|
|
39
49
|
windowKey: z.string().min(1),
|
|
@@ -58,6 +68,7 @@ export const CapacityWindowSnapshot = z.object({
|
|
|
58
68
|
expiresAt: z.string().nullable(),
|
|
59
69
|
source: CapacitySource,
|
|
60
70
|
unavailableReason: CapacityUnavailableReason.nullable(),
|
|
71
|
+
criticalRole: CapacityCriticalRole.nullable(),
|
|
61
72
|
});
|
|
62
73
|
export const RuntimeCapacityEntry = z.object({
|
|
63
74
|
runtime: Runtime,
|
|
@@ -189,6 +200,55 @@ export function isTeamBillingKnown(b, nowMs = Date.now()) {
|
|
|
189
200
|
}
|
|
190
201
|
return true;
|
|
191
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* NOT-250: monthly/billing-cycle capacity for Cursor Individual accounts via
|
|
205
|
+
* an opt-in *experimental* dashboard adapter. Cursor documents monthly
|
|
206
|
+
* individual usage (Spending dashboard) but exposes no supported usage API,
|
|
207
|
+
* so this surface reads authenticated dashboard endpoints using the existing
|
|
208
|
+
* local Cursor login. Those endpoints and credential formats are not a
|
|
209
|
+
* supported public contract and may change without notice — hence disabled
|
|
210
|
+
* by default, `experimental_api` source, and N/A degradation on any drift.
|
|
211
|
+
* Billing-cycle values only (label/start/end, reported usage, remaining
|
|
212
|
+
* percent): no 5H/1W-style windows are invented, and money is never rendered
|
|
213
|
+
* as a token percentage. N/A reasons reuse CapacityUnavailableReason.
|
|
214
|
+
*/
|
|
215
|
+
export const CursorIndividualBilling = z.object({
|
|
216
|
+
/** True only with explicit opt-in (`AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY=experimental`). */
|
|
217
|
+
enabled: z.boolean(),
|
|
218
|
+
/** True only when opted in AND a usable local credential was found. */
|
|
219
|
+
configured: z.boolean(),
|
|
220
|
+
/** Billing-cycle label exactly as reported (e.g. `September 2026`), never guessed. */
|
|
221
|
+
cycleLabel: z.string().nullable(),
|
|
222
|
+
/** Billing-cycle start as reported (ISO-8601, nullable). */
|
|
223
|
+
cycleStart: z.string().nullable(),
|
|
224
|
+
/** Billing-cycle reset/end as reported (ISO-8601, nullable). */
|
|
225
|
+
cycleEnd: z.string().nullable(),
|
|
226
|
+
/** Reported usage value/unit as reported (never normalized away). */
|
|
227
|
+
usageValue: z.number().nullable(),
|
|
228
|
+
usageUnit: z.string().nullable(),
|
|
229
|
+
/** Normalized remaining percent 0–100 when derivable; null renders N/A. */
|
|
230
|
+
remainingPercent: z.number().min(0).max(100).nullable(),
|
|
231
|
+
source: CapacitySource,
|
|
232
|
+
unavailableReason: CapacityUnavailableReason.nullable(),
|
|
233
|
+
/** When the backing dashboard read was observed (ISO-8601, nullable). */
|
|
234
|
+
observedAt: z.string().nullable(),
|
|
235
|
+
generatedAt: z.string(),
|
|
236
|
+
});
|
|
237
|
+
/** True when the individual billing snapshot carries current, renderable values. */
|
|
238
|
+
export function isIndividualBillingKnown(b, nowMs = Date.now()) {
|
|
239
|
+
if (!b.enabled || !b.configured)
|
|
240
|
+
return false;
|
|
241
|
+
if (b.source === "unavailable" || b.unavailableReason !== null)
|
|
242
|
+
return false;
|
|
243
|
+
if (b.usageValue === null && b.remainingPercent === null)
|
|
244
|
+
return false;
|
|
245
|
+
if (b.observedAt) {
|
|
246
|
+
const obsMs = Date.parse(b.observedAt);
|
|
247
|
+
if (!Number.isFinite(obsMs) || obsMs > nowMs + 60_000)
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
192
252
|
/** True when the window carries a current, renderable remaining value. */
|
|
193
253
|
export function isWindowKnown(w, nowMs = Date.now()) {
|
|
194
254
|
if (w.remainingPercent === null)
|
package/dist/doctor.d.ts
CHANGED
|
@@ -1 +1,58 @@
|
|
|
1
1
|
export declare function runDoctor(): Promise<number>;
|
|
2
|
+
/** Which local login the Individual adapter would use, if any. */
|
|
3
|
+
export type CursorIndividualLoginKind = "desktop" | "agent" | "none";
|
|
4
|
+
export interface CursorIndividualLoginReport {
|
|
5
|
+
kind: CursorIndividualLoginKind;
|
|
6
|
+
/** Static operator-facing line — no paths, tokens, or user ids, ever. */
|
|
7
|
+
line: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const CURSOR_INDIVIDUAL_LOGIN_LINES: Record<CursorIndividualLoginKind, string>;
|
|
10
|
+
/**
|
|
11
|
+
* Map a diagnostics-safe credential status to the doctor line. Pure (no
|
|
12
|
+
* I/O): unit tests pin all three states plus the degraded inputs here.
|
|
13
|
+
*/
|
|
14
|
+
export declare function describeCursorIndividualLogin(status: {
|
|
15
|
+
present: boolean;
|
|
16
|
+
source: "desktop" | "agent" | null;
|
|
17
|
+
} | null | undefined): CursorIndividualLoginReport;
|
|
18
|
+
/**
|
|
19
|
+
* Probe the local Cursor logins the way the adapter would (desktop first,
|
|
20
|
+
* Agent auth as the fallback) and report the kind. Never throws, never
|
|
21
|
+
* prints secret material — the line is one of three static strings.
|
|
22
|
+
*/
|
|
23
|
+
export declare function checkCursorIndividualLogin(): Promise<CursorIndividualLoginReport>;
|
|
24
|
+
/** Env override for the Claude cache file (tests/smoke). */
|
|
25
|
+
export declare const CLAUDE_CAPACITY_CACHE_FILE_ENV = "AGENT_DEALER_CLAUDE_CACHE_FILE";
|
|
26
|
+
/** Paid-fallback opt-in env and its only enabling value. */
|
|
27
|
+
export declare const CLAUDE_CAPACITY_REFRESH_ENV = "AGENT_DEALER_CLAUDE_CAPACITY_REFRESH";
|
|
28
|
+
export declare const CLAUDE_CAPACITY_REFRESH_PAID_VALUE = "paid-after-1h";
|
|
29
|
+
/** Which local 5H/1W cache state the capacity ladder would read. */
|
|
30
|
+
export type ClaudeCapacitySourceKind = "fresh" | "stale" | "missing";
|
|
31
|
+
export interface ClaudeCapacitySourceReport {
|
|
32
|
+
kind: ClaudeCapacitySourceKind;
|
|
33
|
+
/** Static operator-facing line — age labels only, never values or ids. */
|
|
34
|
+
line: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Map a cache-file presence/age probe to the doctor line. Pure (no I/O):
|
|
38
|
+
* unit tests pin all three states here.
|
|
39
|
+
*/
|
|
40
|
+
export declare function describeClaudeCapacitySource(status: {
|
|
41
|
+
present: boolean;
|
|
42
|
+
ageMs: number | null;
|
|
43
|
+
} | null | undefined): ClaudeCapacitySourceReport;
|
|
44
|
+
/**
|
|
45
|
+
* Map the paid-fallback setting to an opt-in warning line, or null when the
|
|
46
|
+
* probe is disabled (the quiet default — no spend possible, nothing to say).
|
|
47
|
+
* Pure (no I/O): unit tests pin both states here.
|
|
48
|
+
*/
|
|
49
|
+
export declare function describeClaudeProbeOptIn(setting: string | undefined): string | null;
|
|
50
|
+
/**
|
|
51
|
+
* Read the `cachedUsageUtilization.fetchedAtMs` timestamp out of Claude
|
|
52
|
+
* Code's config file (override first, then `~/.claude.json`) — the same key
|
|
53
|
+
* the server adapter normalizes, so doctor and the server always agree on
|
|
54
|
+
* freshness. File mtime is meaningless here: unrelated config writes touch
|
|
55
|
+
* it. Only the timestamp is read (never utilization values, ids, or
|
|
56
|
+
* siblings) and only an age label is reported. Never throws.
|
|
57
|
+
*/
|
|
58
|
+
export declare function checkClaudeCapacitySource(nowMs?: number): Promise<ClaudeCapacitySourceReport>;
|
package/dist/doctor.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
6
|
import { cursorAuthIssueFromOutput } from "@agent-dealer/shared";
|
|
5
7
|
import { claudeAvailable, cursorAvailable } from "./cli-check.js";
|
|
6
8
|
import { loadProdEnvFile, prodEnvFilePath, prodHomeDir, resolveBundledListenPort, shortenHome, } from "./env.js";
|
|
@@ -105,6 +107,39 @@ export async function runDoctor() {
|
|
|
105
107
|
else {
|
|
106
108
|
console.warn("⚠ LINEAR_API_KEY not set — Linear inbox disabled");
|
|
107
109
|
}
|
|
110
|
+
// NOT-268: Claude account capacity source status (informational only —
|
|
111
|
+
// never fails doctor). Reports whether Claude Code's own local 5H/1W
|
|
112
|
+
// cache exists and how old it is, plus whether the paid fallback probe
|
|
113
|
+
// is armed. Age labels only: never utilization values, prompts, tokens,
|
|
114
|
+
// or account ids.
|
|
115
|
+
try {
|
|
116
|
+
console.log((await checkClaudeCapacitySource()).line);
|
|
117
|
+
const probe = describeClaudeProbeOptIn(process.env.AGENT_DEALER_CLAUDE_CAPACITY_REFRESH);
|
|
118
|
+
if (probe)
|
|
119
|
+
console.warn(probe);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Informational only: a broken probe must never fail doctor.
|
|
123
|
+
}
|
|
124
|
+
// NOT-250: experimental Cursor Individual dashboard adapter status
|
|
125
|
+
// (informational only — never fails doctor). NOT-267: when enabled,
|
|
126
|
+
// also report which local login the adapter would use — desktop first,
|
|
127
|
+
// Agent auth as the fallback. The probe only runs under the explicit
|
|
128
|
+
// opt-in, and prints kind labels only: never paths, tokens, or user ids.
|
|
129
|
+
if (process.env.AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY === "experimental") {
|
|
130
|
+
console.warn("⚠ Cursor Individual capacity: EXPERIMENTAL dashboard adapter enabled " +
|
|
131
|
+
"(undocumented API, no support guarantee — unset AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY to disable)");
|
|
132
|
+
try {
|
|
133
|
+
const login = await checkCursorIndividualLogin();
|
|
134
|
+
if (login.kind === "none")
|
|
135
|
+
console.warn(login.line);
|
|
136
|
+
else
|
|
137
|
+
console.log(login.line);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Informational only: a broken probe must never fail doctor.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
108
143
|
}
|
|
109
144
|
else {
|
|
110
145
|
console.warn(`⚠ no config — run: agent-dealer setup`);
|
|
@@ -145,6 +180,182 @@ export async function runDoctor() {
|
|
|
145
180
|
}
|
|
146
181
|
return failed ? 1 : 0;
|
|
147
182
|
}
|
|
183
|
+
export const CURSOR_INDIVIDUAL_LOGIN_LINES = {
|
|
184
|
+
desktop: "✓ Cursor Individual capacity: desktop login found (experimental dashboard adapter can use it)",
|
|
185
|
+
agent: "✓ Cursor Individual capacity: Agent login found (fallback)",
|
|
186
|
+
none: "⚠ Cursor Individual capacity: no usable local login — log in to Cursor desktop or the Cursor Agent CLI",
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Map a diagnostics-safe credential status to the doctor line. Pure (no
|
|
190
|
+
* I/O): unit tests pin all three states plus the degraded inputs here.
|
|
191
|
+
*/
|
|
192
|
+
export function describeCursorIndividualLogin(status) {
|
|
193
|
+
if (status?.present === true && status.source === "desktop") {
|
|
194
|
+
return { kind: "desktop", line: CURSOR_INDIVIDUAL_LOGIN_LINES.desktop };
|
|
195
|
+
}
|
|
196
|
+
if (status?.present === true && status.source === "agent") {
|
|
197
|
+
return { kind: "agent", line: CURSOR_INDIVIDUAL_LOGIN_LINES.agent };
|
|
198
|
+
}
|
|
199
|
+
return { kind: "none", line: CURSOR_INDIVIDUAL_LOGIN_LINES.none };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Load the server's credential-status reader without depending on the
|
|
203
|
+
* server's daemon entrypoint (which has side effects on import). The
|
|
204
|
+
* checkout layout (TypeScript source, run under tsx) is tried first, then
|
|
205
|
+
* the installed layout (built dist) — whichever answers wins. Returns null
|
|
206
|
+
* when neither is loadable; the caller then reports "no usable local
|
|
207
|
+
* login", never a crash. The loaded reader itself returns presence/source
|
|
208
|
+
* only — secret material never crosses this boundary by construction.
|
|
209
|
+
*/
|
|
210
|
+
async function loadCredentialStatusReader() {
|
|
211
|
+
const roots = [];
|
|
212
|
+
try {
|
|
213
|
+
const { resolveServerRoot } = await import("./paths.js");
|
|
214
|
+
roots.push(resolveServerRoot());
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// The installed package entry (dist) may not be built in a checkout —
|
|
218
|
+
// the workspace fallback below still locates the source.
|
|
219
|
+
}
|
|
220
|
+
// Workspace-checkout fallback: packages/cli/src → packages/server.
|
|
221
|
+
// Absent in installed layouts (no src shipped), where existsSync skips it.
|
|
222
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
223
|
+
roots.push(path.resolve(here, "..", "..", "server"));
|
|
224
|
+
const candidates = roots.flatMap((serverRoot) => [
|
|
225
|
+
path.join(serverRoot, "src", "capacity", "cursor-individual-credentials.ts"),
|
|
226
|
+
path.join(serverRoot, "dist", "capacity", "cursor-individual-credentials.js"),
|
|
227
|
+
]);
|
|
228
|
+
for (const file of candidates) {
|
|
229
|
+
try {
|
|
230
|
+
if (!fs.existsSync(file))
|
|
231
|
+
continue;
|
|
232
|
+
const mod = (await import(pathToFileURL(file).href));
|
|
233
|
+
if (typeof mod.cursorIndividualCredentialStatus === "function") {
|
|
234
|
+
return mod;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Probe the local Cursor logins the way the adapter would (desktop first,
|
|
245
|
+
* Agent auth as the fallback) and report the kind. Never throws, never
|
|
246
|
+
* prints secret material — the line is one of three static strings.
|
|
247
|
+
*/
|
|
248
|
+
export async function checkCursorIndividualLogin() {
|
|
249
|
+
try {
|
|
250
|
+
const reader = await loadCredentialStatusReader();
|
|
251
|
+
if (!reader)
|
|
252
|
+
return describeCursorIndividualLogin(null);
|
|
253
|
+
return describeCursorIndividualLogin(reader.cursorIndividualCredentialStatus());
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return describeCursorIndividualLogin(null);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// NOT-268: Claude capacity source reporting for doctor.
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
/** Env override for the Claude cache file (tests/smoke). */
|
|
263
|
+
export const CLAUDE_CAPACITY_CACHE_FILE_ENV = "AGENT_DEALER_CLAUDE_CACHE_FILE";
|
|
264
|
+
/** Paid-fallback opt-in env and its only enabling value. */
|
|
265
|
+
export const CLAUDE_CAPACITY_REFRESH_ENV = "AGENT_DEALER_CLAUDE_CAPACITY_REFRESH";
|
|
266
|
+
export const CLAUDE_CAPACITY_REFRESH_PAID_VALUE = "paid-after-1h";
|
|
267
|
+
/**
|
|
268
|
+
* Map a cache-file presence/age probe to the doctor line. Pure (no I/O):
|
|
269
|
+
* unit tests pin all three states here.
|
|
270
|
+
*/
|
|
271
|
+
export function describeClaudeCapacitySource(status) {
|
|
272
|
+
if (status?.present === true && typeof status.ageMs === "number" && status.ageMs >= 0) {
|
|
273
|
+
if (status.ageMs < 60 * 60 * 1000) {
|
|
274
|
+
return {
|
|
275
|
+
kind: "fresh",
|
|
276
|
+
line: `✓ Claude capacity: local 5H/1W cache fresh (${formatCacheAge(status.ageMs)} old)`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
kind: "stale",
|
|
281
|
+
line: `⚠ Claude capacity: local 5H/1W cache stale (${formatCacheAge(status.ageMs)} old — refreshes on the next Claude run)`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
kind: "missing",
|
|
286
|
+
line: "⚠ Claude capacity: no local 5H/1W cache yet — run Claude Code once to populate it",
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function formatCacheAge(ageMs) {
|
|
290
|
+
const minutes = Math.floor(ageMs / 60_000);
|
|
291
|
+
if (minutes < 1)
|
|
292
|
+
return "under a minute";
|
|
293
|
+
if (minutes < 60)
|
|
294
|
+
return `${minutes}m`;
|
|
295
|
+
const hours = Math.floor(minutes / 60);
|
|
296
|
+
if (hours < 48)
|
|
297
|
+
return `${hours}h`;
|
|
298
|
+
return `${Math.floor(hours / 24)}d`;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Map the paid-fallback setting to an opt-in warning line, or null when the
|
|
302
|
+
* probe is disabled (the quiet default — no spend possible, nothing to say).
|
|
303
|
+
* Pure (no I/O): unit tests pin both states here.
|
|
304
|
+
*/
|
|
305
|
+
export function describeClaudeProbeOptIn(setting) {
|
|
306
|
+
if (setting === CLAUDE_CAPACITY_REFRESH_PAID_VALUE) {
|
|
307
|
+
return ("⚠ Claude capacity paid fallback: armed (one ≤$0.01 Haiku probe after 1h stale — " +
|
|
308
|
+
`unset ${CLAUDE_CAPACITY_REFRESH_ENV} to disable)`);
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Read the `cachedUsageUtilization.fetchedAtMs` timestamp out of Claude
|
|
314
|
+
* Code's config file (override first, then `~/.claude.json`) — the same key
|
|
315
|
+
* the server adapter normalizes, so doctor and the server always agree on
|
|
316
|
+
* freshness. File mtime is meaningless here: unrelated config writes touch
|
|
317
|
+
* it. Only the timestamp is read (never utilization values, ids, or
|
|
318
|
+
* siblings) and only an age label is reported. Never throws.
|
|
319
|
+
*/
|
|
320
|
+
export async function checkClaudeCapacitySource(nowMs = Date.now()) {
|
|
321
|
+
try {
|
|
322
|
+
const override = process.env[CLAUDE_CAPACITY_CACHE_FILE_ENV]?.trim();
|
|
323
|
+
const file = override || path.join(process.env.HOME ?? os.homedir(), ".claude.json");
|
|
324
|
+
let text;
|
|
325
|
+
try {
|
|
326
|
+
text = fs.readFileSync(file, "utf8");
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return describeClaudeCapacitySource(null);
|
|
330
|
+
}
|
|
331
|
+
let parsed;
|
|
332
|
+
try {
|
|
333
|
+
parsed = JSON.parse(text);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return describeClaudeCapacitySource(null);
|
|
337
|
+
}
|
|
338
|
+
const root = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
339
|
+
? parsed
|
|
340
|
+
: null;
|
|
341
|
+
// A bare cache object (no config envelope — the shape override fixtures
|
|
342
|
+
// use) is accepted as-is; otherwise only the subtree counts.
|
|
343
|
+
const subtreeRaw = root?.cachedUsageUtilization;
|
|
344
|
+
const subtree = subtreeRaw && typeof subtreeRaw === "object" && !Array.isArray(subtreeRaw)
|
|
345
|
+
? subtreeRaw
|
|
346
|
+
: root;
|
|
347
|
+
const fetchedAtMs = subtree && typeof subtree.fetchedAtMs === "number" && Number.isFinite(subtree.fetchedAtMs)
|
|
348
|
+
? subtree.fetchedAtMs
|
|
349
|
+
: null;
|
|
350
|
+
if (fetchedAtMs === null || fetchedAtMs <= 0 || fetchedAtMs > nowMs) {
|
|
351
|
+
return describeClaudeCapacitySource(null);
|
|
352
|
+
}
|
|
353
|
+
return describeClaudeCapacitySource({ present: true, ageMs: nowMs - fetchedAtMs });
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return describeClaudeCapacitySource(null);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
148
359
|
function isPortFree(port) {
|
|
149
360
|
return new Promise((resolve) => {
|
|
150
361
|
const server = net.createServer();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// packages/cli/src/doctor.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-267: doctor reports which local login the experimental Cursor
|
|
4
|
+
// Individual adapter would use — desktop, Agent fallback, or none — using
|
|
5
|
+
// static lines only. Tests never touch the real local login: every case
|
|
6
|
+
// points the resolution at temporary fixtures.
|
|
7
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import Database from "better-sqlite3";
|
|
13
|
+
// Local mirror of the server module's env/key contract
|
|
14
|
+
// (packages/server/src/capacity/cursor-individual-credentials.ts). A static
|
|
15
|
+
// import is impossible — CLI `rootDir` cannot include server sources — so
|
|
16
|
+
// the mirror is pinned against the live server module by the contract test
|
|
17
|
+
// below: a rename there fails that test loudly instead of letting these
|
|
18
|
+
// fixtures silently stop applying (and probe the real local login).
|
|
19
|
+
const CURSOR_DESKTOP_ACCESS_TOKEN_KEY = "cursorAuth/accessToken";
|
|
20
|
+
const CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV = "CURSOR_INDIVIDUAL_CREDENTIAL_FILE";
|
|
21
|
+
const CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV = "CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE";
|
|
22
|
+
const CURSOR_INDIVIDUAL_HOME_ENV = "CURSOR_INDIVIDUAL_HOME";
|
|
23
|
+
test("fixture env/key names match the server module's live contract", async () => {
|
|
24
|
+
const { pathToFileURL, fileURLToPath } = await import("node:url");
|
|
25
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const candidates = [
|
|
27
|
+
path.resolve(here, "..", "..", "server", "src", "capacity", "cursor-individual-credentials.ts"),
|
|
28
|
+
path.resolve(here, "..", "..", "server", "dist", "capacity", "cursor-individual-credentials.js"),
|
|
29
|
+
];
|
|
30
|
+
let mod = null;
|
|
31
|
+
for (const file of candidates) {
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.existsSync(file))
|
|
34
|
+
continue;
|
|
35
|
+
mod = (await import(pathToFileURL(file).href));
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
assert.ok(mod, "the server credentials module must be loadable for the contract pin");
|
|
43
|
+
assert.equal(mod.CURSOR_DESKTOP_ACCESS_TOKEN_KEY, CURSOR_DESKTOP_ACCESS_TOKEN_KEY);
|
|
44
|
+
assert.equal(mod.CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV, CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV);
|
|
45
|
+
assert.equal(mod.CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV, CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV);
|
|
46
|
+
assert.equal(mod.CURSOR_INDIVIDUAL_HOME_ENV, CURSOR_INDIVIDUAL_HOME_ENV);
|
|
47
|
+
});
|
|
48
|
+
import { CLAUDE_CAPACITY_CACHE_FILE_ENV, CLAUDE_CAPACITY_REFRESH_ENV, CLAUDE_CAPACITY_REFRESH_PAID_VALUE, CURSOR_INDIVIDUAL_LOGIN_LINES, checkClaudeCapacitySource, checkCursorIndividualLogin, describeClaudeCapacitySource, describeClaudeProbeOptIn, describeCursorIndividualLogin, } from "./doctor.js";
|
|
49
|
+
const SECRET = "doctor-fixture-secret-must-never-print";
|
|
50
|
+
const USER_ID = "user_doctor_fixture";
|
|
51
|
+
function fixtureJwt(sub) {
|
|
52
|
+
const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
53
|
+
return `${b64url({ alg: "none" })}.${b64url({ sub })}.sig`;
|
|
54
|
+
}
|
|
55
|
+
let dir;
|
|
56
|
+
const saved = {};
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-doctor-indiv-"));
|
|
59
|
+
for (const key of [
|
|
60
|
+
CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV,
|
|
61
|
+
CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV,
|
|
62
|
+
CURSOR_INDIVIDUAL_HOME_ENV,
|
|
63
|
+
CLAUDE_CAPACITY_CACHE_FILE_ENV,
|
|
64
|
+
]) {
|
|
65
|
+
saved[key] = process.env[key];
|
|
66
|
+
delete process.env[key];
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
71
|
+
if (value === undefined)
|
|
72
|
+
delete process.env[key];
|
|
73
|
+
else
|
|
74
|
+
process.env[key] = value;
|
|
75
|
+
}
|
|
76
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
test("describe maps each credential state to its static line", () => {
|
|
79
|
+
assert.deepEqual(describeCursorIndividualLogin({ present: true, source: "desktop" }), {
|
|
80
|
+
kind: "desktop",
|
|
81
|
+
line: CURSOR_INDIVIDUAL_LOGIN_LINES.desktop,
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(describeCursorIndividualLogin({ present: true, source: "agent" }), {
|
|
84
|
+
kind: "agent",
|
|
85
|
+
line: CURSOR_INDIVIDUAL_LOGIN_LINES.agent,
|
|
86
|
+
});
|
|
87
|
+
const degraded = [
|
|
88
|
+
{ present: false, source: null },
|
|
89
|
+
{ present: false, source: "desktop" },
|
|
90
|
+
{ present: true, source: null },
|
|
91
|
+
null,
|
|
92
|
+
undefined,
|
|
93
|
+
];
|
|
94
|
+
for (const state of degraded) {
|
|
95
|
+
assert.deepEqual(describeCursorIndividualLogin(state), {
|
|
96
|
+
kind: "none",
|
|
97
|
+
line: CURSOR_INDIVIDUAL_LOGIN_LINES.none,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
test("the three lines name the three states and carry no paths or secrets", () => {
|
|
102
|
+
assert.match(CURSOR_INDIVIDUAL_LOGIN_LINES.desktop, /desktop login found/);
|
|
103
|
+
assert.match(CURSOR_INDIVIDUAL_LOGIN_LINES.agent, /Agent login found/);
|
|
104
|
+
assert.match(CURSOR_INDIVIDUAL_LOGIN_LINES.none, /no usable local login/);
|
|
105
|
+
for (const line of Object.values(CURSOR_INDIVIDUAL_LOGIN_LINES)) {
|
|
106
|
+
assert.ok(!line.includes("/"), "no filesystem paths in doctor output");
|
|
107
|
+
assert.ok(!line.includes(SECRET));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
function fixtureDesktopDb(token) {
|
|
111
|
+
const file = path.join(dir, "state.vscdb");
|
|
112
|
+
const db = new Database(file);
|
|
113
|
+
try {
|
|
114
|
+
db.exec("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)");
|
|
115
|
+
db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)").run(CURSOR_DESKTOP_ACCESS_TOKEN_KEY, token);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
db.close();
|
|
119
|
+
}
|
|
120
|
+
process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV] = file;
|
|
121
|
+
return file;
|
|
122
|
+
}
|
|
123
|
+
test("check reports desktop login found for a fixture state.vscdb", async () => {
|
|
124
|
+
process.env[CURSOR_INDIVIDUAL_HOME_ENV] = dir;
|
|
125
|
+
const jwt = fixtureJwt(USER_ID);
|
|
126
|
+
const dbFile = fixtureDesktopDb(jwt);
|
|
127
|
+
const report = await checkCursorIndividualLogin();
|
|
128
|
+
assert.equal(report.kind, "desktop");
|
|
129
|
+
assert.equal(report.line, CURSOR_INDIVIDUAL_LOGIN_LINES.desktop);
|
|
130
|
+
assert.ok(!report.line.includes(jwt), "token material must not print");
|
|
131
|
+
assert.ok(!report.line.includes(dbFile), "database paths must not print");
|
|
132
|
+
});
|
|
133
|
+
test("check reports Agent login found when only the Agent auth file answers", async () => {
|
|
134
|
+
process.env[CURSOR_INDIVIDUAL_HOME_ENV] = dir;
|
|
135
|
+
process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV] = path.join(dir, "missing.vscdb");
|
|
136
|
+
const agentFile = path.join(dir, ".cursor", "auth.json");
|
|
137
|
+
fs.mkdirSync(path.dirname(agentFile), { recursive: true });
|
|
138
|
+
fs.writeFileSync(agentFile, JSON.stringify({ token: SECRET, userId: USER_ID }));
|
|
139
|
+
const report = await checkCursorIndividualLogin();
|
|
140
|
+
assert.equal(report.kind, "agent");
|
|
141
|
+
assert.equal(report.line, CURSOR_INDIVIDUAL_LOGIN_LINES.agent);
|
|
142
|
+
assert.ok(!report.line.includes(SECRET), "token material must not print");
|
|
143
|
+
assert.ok(!report.line.includes(agentFile), "credential paths must not print");
|
|
144
|
+
});
|
|
145
|
+
test("check reports no usable local login when neither source answers", async () => {
|
|
146
|
+
process.env[CURSOR_INDIVIDUAL_HOME_ENV] = dir;
|
|
147
|
+
process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV] = path.join(dir, "missing.vscdb");
|
|
148
|
+
const report = await checkCursorIndividualLogin();
|
|
149
|
+
assert.equal(report.kind, "none");
|
|
150
|
+
assert.equal(report.line, CURSOR_INDIVIDUAL_LOGIN_LINES.none);
|
|
151
|
+
});
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// NOT-268: Claude capacity source reporting. Tests never touch the real
|
|
154
|
+
// ~/.claude.json: every case points the override at a temporary fixture.
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
test("fixture cache-file env name matches the server module's live contract", async () => {
|
|
157
|
+
const { fileURLToPath } = await import("node:url");
|
|
158
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
159
|
+
const candidates = [
|
|
160
|
+
path.resolve(here, "..", "..", "server", "src", "capacity", "claude-local-cache.ts"),
|
|
161
|
+
path.resolve(here, "..", "..", "server", "dist", "capacity", "claude-local-cache.js"),
|
|
162
|
+
];
|
|
163
|
+
const src = candidates.find((file) => fs.existsSync(file));
|
|
164
|
+
assert.ok(src, "the server cache module must exist for the contract pin");
|
|
165
|
+
const text = fs.readFileSync(src, "utf8");
|
|
166
|
+
assert.ok(text.includes(`"${CLAUDE_CAPACITY_CACHE_FILE_ENV}"`));
|
|
167
|
+
assert.ok(text.includes(`"${CLAUDE_CAPACITY_REFRESH_ENV}"`));
|
|
168
|
+
assert.ok(text.includes(`"${CLAUDE_CAPACITY_REFRESH_PAID_VALUE}"`));
|
|
169
|
+
});
|
|
170
|
+
test("describe maps each cache state to its static line", () => {
|
|
171
|
+
const fresh = describeClaudeCapacitySource({ present: true, ageMs: 5 * 60_000 });
|
|
172
|
+
assert.equal(fresh.kind, "fresh");
|
|
173
|
+
assert.match(fresh.line, /fresh/);
|
|
174
|
+
const stale = describeClaudeCapacitySource({ present: true, ageMs: 61 * 60_000 });
|
|
175
|
+
assert.equal(stale.kind, "stale");
|
|
176
|
+
assert.match(stale.line, /stale/);
|
|
177
|
+
const degraded = [
|
|
178
|
+
{ present: false, ageMs: null },
|
|
179
|
+
{ present: false, ageMs: 0 },
|
|
180
|
+
{ present: true, ageMs: null },
|
|
181
|
+
{ present: true, ageMs: -1 },
|
|
182
|
+
null,
|
|
183
|
+
undefined,
|
|
184
|
+
];
|
|
185
|
+
for (const state of degraded) {
|
|
186
|
+
const report = describeClaudeCapacitySource(state);
|
|
187
|
+
assert.equal(report.kind, "missing");
|
|
188
|
+
assert.match(report.line, /no local 5H\/1W cache/);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
test("probe opt-in line appears only under the exact paid value", () => {
|
|
192
|
+
assert.equal(describeClaudeProbeOptIn(undefined), null);
|
|
193
|
+
assert.equal(describeClaudeProbeOptIn(""), null);
|
|
194
|
+
assert.equal(describeClaudeProbeOptIn("off"), null);
|
|
195
|
+
assert.equal(describeClaudeProbeOptIn("auto"), null);
|
|
196
|
+
const armed = describeClaudeProbeOptIn(CLAUDE_CAPACITY_REFRESH_PAID_VALUE);
|
|
197
|
+
assert.ok(armed);
|
|
198
|
+
assert.match(armed, /armed/);
|
|
199
|
+
assert.match(armed, /≤\$0\.01/);
|
|
200
|
+
});
|
|
201
|
+
test("check reads fetchedAtMs from the config key, never mtime or the real home", async () => {
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
// Full config envelope with sensitive siblings: freshness comes from the
|
|
204
|
+
// subtree timestamp even though mtime is two days old (mtime must be
|
|
205
|
+
// ignored — unrelated config writes touch it). The line must not leak
|
|
206
|
+
// the sibling values.
|
|
207
|
+
const freshFile = path.join(dir, "cached-fresh.json");
|
|
208
|
+
fs.writeFileSync(freshFile, JSON.stringify({
|
|
209
|
+
userID: "user-secret-must-never-print",
|
|
210
|
+
email: "someone@example.com",
|
|
211
|
+
cachedUsageUtilization: { fetchedAtMs: now - 5 * 60_000, accountUuid: "acct-secret" },
|
|
212
|
+
}));
|
|
213
|
+
const ancient = new Date(now - 2 * 24 * 3600_000);
|
|
214
|
+
fs.utimesSync(freshFile, ancient, ancient);
|
|
215
|
+
process.env[CLAUDE_CAPACITY_CACHE_FILE_ENV] = freshFile;
|
|
216
|
+
const fresh = await checkClaudeCapacitySource(now);
|
|
217
|
+
assert.equal(fresh.kind, "fresh");
|
|
218
|
+
assert.ok(!fresh.line.includes("user-secret-must-never-print"));
|
|
219
|
+
assert.ok(!fresh.line.includes("acct-secret"));
|
|
220
|
+
// Bare cache object (no envelope) still works; a brand-new mtime cannot
|
|
221
|
+
// rescue a stale timestamp.
|
|
222
|
+
const staleFile = path.join(dir, "cached-stale.json");
|
|
223
|
+
fs.writeFileSync(staleFile, JSON.stringify({ fetchedAtMs: 1 }));
|
|
224
|
+
const justNow = new Date(now);
|
|
225
|
+
fs.utimesSync(staleFile, justNow, justNow);
|
|
226
|
+
process.env[CLAUDE_CAPACITY_CACHE_FILE_ENV] = staleFile;
|
|
227
|
+
const stale = await checkClaudeCapacitySource(now);
|
|
228
|
+
assert.equal(stale.kind, "stale");
|
|
229
|
+
// Missing file, garbage JSON, future timestamp, and a config without the
|
|
230
|
+
// key all report missing.
|
|
231
|
+
process.env[CLAUDE_CAPACITY_CACHE_FILE_ENV] = path.join(dir, "missing.json");
|
|
232
|
+
assert.equal((await checkClaudeCapacitySource(now)).kind, "missing");
|
|
233
|
+
const garbageFile = path.join(dir, "cached-garbage.json");
|
|
234
|
+
fs.writeFileSync(garbageFile, "{not json");
|
|
235
|
+
process.env[CLAUDE_CAPACITY_CACHE_FILE_ENV] = garbageFile;
|
|
236
|
+
assert.equal((await checkClaudeCapacitySource(now)).kind, "missing");
|
|
237
|
+
const futureFile = path.join(dir, "cached-future.json");
|
|
238
|
+
fs.writeFileSync(futureFile, JSON.stringify({ cachedUsageUtilization: { fetchedAtMs: now + 60_000 } }));
|
|
239
|
+
process.env[CLAUDE_CAPACITY_CACHE_FILE_ENV] = futureFile;
|
|
240
|
+
assert.equal((await checkClaudeCapacitySource(now)).kind, "missing");
|
|
241
|
+
const keylessFile = path.join(dir, "cached-keyless.json");
|
|
242
|
+
fs.writeFileSync(keylessFile, JSON.stringify({ userID: "x", projects: {} }));
|
|
243
|
+
process.env[CLAUDE_CAPACITY_CACHE_FILE_ENV] = keylessFile;
|
|
244
|
+
assert.equal((await checkClaudeCapacitySource(now)).kind, "missing");
|
|
245
|
+
delete process.env[CLAUDE_CAPACITY_CACHE_FILE_ENV];
|
|
246
|
+
});
|