@aixle/insights 0.2.0 → 0.2.2-staging
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 +38 -9
- package/dist/auth/credentials.d.ts +7 -1
- package/dist/auth/credentials.js +71 -14
- package/dist/auth/exchange.d.ts +1 -1
- package/dist/auth/exchange.js +1 -1
- package/dist/auth/flow.d.ts +8 -1
- package/dist/auth/flow.js +27 -5
- package/dist/auth/keycloak.d.ts +1 -1
- package/dist/auth/keycloak.js +20 -1
- package/dist/cli.d.ts +5 -3
- package/dist/cli.js +69 -21
- package/dist/collect-cursor-payloads.d.ts +4 -3
- package/dist/collect-cursor-payloads.js +8 -5
- package/dist/cursor-checkpoints.d.ts +2 -2
- package/dist/cursor-payload-contract.d.ts +5 -5
- package/dist/cursor-payload-contract.js +6 -0
- package/dist/cursor-settings.d.ts +9 -4
- package/dist/cursor-settings.js +80 -10
- package/dist/health.d.ts +3 -1
- package/dist/health.js +13 -1
- package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
- package/dist/hooks/cursor-hooks-mapper.js +1 -1
- package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
- package/dist/hooks/cursor-hooks-reader.js +10 -3
- package/dist/install/cursor.d.ts +34 -0
- package/dist/install/cursor.js +193 -0
- package/dist/install/index.d.ts +6 -4
- package/dist/install/index.js +6 -1
- package/dist/lib/client.d.ts +7 -0
- package/dist/lib/client.js +17 -0
- package/dist/lib/config.js +7 -2
- package/dist/lib/project-resolver.d.ts +5 -4
- package/dist/lib/project-resolver.js +57 -11
- package/dist/lib/repo-path-safety.d.ts +35 -0
- package/dist/lib/repo-path-safety.js +102 -0
- package/dist/lib/spawn-arg-safety.d.ts +25 -0
- package/dist/lib/spawn-arg-safety.js +49 -0
- package/dist/lib/transport-security.d.ts +1 -0
- package/dist/lib/transport-security.js +1 -1
- package/dist/readers/claude.d.ts +54 -6
- package/dist/readers/claude.js +154 -2
- package/dist/readers/cursor.d.ts +10 -7
- package/dist/readers/cursor.js +113 -17
- package/dist/risk-scanner.js +7 -0
- package/dist/server.d.ts +20 -3
- package/dist/server.js +101 -67
- package/dist/state.js +7 -2
- package/dist/sync.d.ts +13 -2
- package/dist/sync.js +86 -58
- package/package.json +6 -2
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { DEFAULT_PRICING, mergePricing } from "./pricing.js";
|
|
|
11
11
|
import { resolveCursorPricing } from "./cursor-config.js";
|
|
12
12
|
import { buildHealthSnapshot, formatHealthForCli } from "./health.js";
|
|
13
13
|
import { installClaudeUserMcp } from "./install/claude.js";
|
|
14
|
+
import { installCursorUserMcp, uninstallCursorUserMcp } from "./install/cursor.js";
|
|
14
15
|
import { installHooksConfig, uninstallHooksConfig, verifyHooksConfig, FORWARDER_FILENAME } from "./hooks/hooks-config.js";
|
|
15
16
|
import { evaluateTransportSecurity } from "./lib/transport-security.js";
|
|
16
17
|
import { join } from "node:path";
|
|
@@ -20,9 +21,9 @@ const GLOBAL_FLAGS = new Set(["--help", "-h", "--once", "--full"]);
|
|
|
20
21
|
const INIT_VALUE_FLAGS = new Set(["--host", "--keycloak-url", "--tool-name", "--organization-id"]);
|
|
21
22
|
const INIT_BOOLEAN_FLAGS = new Set(["--force", "--hooks", "--insecure"]);
|
|
22
23
|
/** Matches DB90 Rails `McpController` UUID check for `X-Organization-ID` (RFC 4122 variant). */
|
|
23
|
-
export const
|
|
24
|
-
export function
|
|
25
|
-
return
|
|
24
|
+
export const ORGANIZATION_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
25
|
+
export function isValidOrganizationUuid(value) {
|
|
26
|
+
return ORGANIZATION_UUID_PATTERN.test(value.trim());
|
|
26
27
|
}
|
|
27
28
|
function takeFlagValue(argv, name) {
|
|
28
29
|
const eqForm = argv.find((a) => a.startsWith(`${name}=`));
|
|
@@ -147,7 +148,7 @@ export function parseArgs(argv) {
|
|
|
147
148
|
if (raw === "serve") {
|
|
148
149
|
return { command: "run", help, once, full: full || undefined };
|
|
149
150
|
}
|
|
150
|
-
if (raw === "uninstall-hooks" || raw === "verify-hooks") {
|
|
151
|
+
if (raw === "uninstall-hooks" || raw === "verify-hooks" || raw === "uninstall-cursor-mcp") {
|
|
151
152
|
return { command: raw, help: false, once: false };
|
|
152
153
|
}
|
|
153
154
|
return { command: "help", help: true, once: false };
|
|
@@ -160,11 +161,12 @@ Usage:
|
|
|
160
161
|
aixle-insights [command] [options]
|
|
161
162
|
|
|
162
163
|
Commands:
|
|
163
|
-
run
|
|
164
|
-
init
|
|
165
|
-
health
|
|
166
|
-
uninstall-hooks
|
|
167
|
-
verify-hooks
|
|
164
|
+
run Start the MCP stdio server (default — used by Claude Code).
|
|
165
|
+
init Keycloak device login once, then persist DB90 ingest credentials (keychain or file).
|
|
166
|
+
health Multi-line diagnostic (credentials, sync, log path, state files).
|
|
167
|
+
uninstall-hooks Remove DB90 from ~/.cursor/hooks.json and restore backup (if any).
|
|
168
|
+
verify-hooks Print hooks install status and queue depth as JSON.
|
|
169
|
+
uninstall-cursor-mcp Remove aixle-insights from ~/.cursor/mcp.json and restore backup (if any).
|
|
168
170
|
|
|
169
171
|
Options:
|
|
170
172
|
--once With 'run': perform a multi-tool sync then exit (no MCP server).
|
|
@@ -176,7 +178,8 @@ init options:
|
|
|
176
178
|
--keycloak-url <issuer> Keycloak realm issuer (default: env KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER)
|
|
177
179
|
--tool-name <name> Optional: mint only \`claude_code\`, only \`cursor\`, or omit to mint BOTH.
|
|
178
180
|
--organization-id <uuid> Optional: scope MCP token exchange to this org (overrides env DB90_ORGANIZATION_ID).
|
|
179
|
-
--force
|
|
181
|
+
--force Re-run the device flow even if valid credentials already exist, and
|
|
182
|
+
replace an existing user "aixle-insights" MCP entry in ~/.claude.json if it differs.
|
|
180
183
|
--hooks (opt-in) Install Cursor hook forwarder for per-turn model attribution.
|
|
181
184
|
Requires Cursor restart. Run 'aixle-insights uninstall-hooks' to remove.
|
|
182
185
|
--insecure Allow remote http:// hosts for trusted non-production test endpoints only.
|
|
@@ -191,7 +194,7 @@ Credentials:
|
|
|
191
194
|
Note: Omitting --tool-name provisions separate ingest tokens for Claude Code + Cursor behind a single Keycloak login.
|
|
192
195
|
`);
|
|
193
196
|
}
|
|
194
|
-
function
|
|
197
|
+
function defaultApiHost() {
|
|
195
198
|
const v = process.env["DB90_API_URL"]?.trim();
|
|
196
199
|
if (v)
|
|
197
200
|
return v.replace(/\/$/, "");
|
|
@@ -211,12 +214,13 @@ export async function runInit(cliArgs, deps) {
|
|
|
211
214
|
defaultKeycloakIssuer,
|
|
212
215
|
getAppDir,
|
|
213
216
|
installClaudeUserMcp,
|
|
217
|
+
installCursorUserMcp,
|
|
214
218
|
log: console.log,
|
|
215
219
|
error: console.error,
|
|
216
220
|
...deps,
|
|
217
221
|
};
|
|
218
|
-
const
|
|
219
|
-
const transportSecurity = evaluateTransportSecurity(
|
|
222
|
+
const apiHost = (cliArgs.host ?? defaultApiHost()).replace(/\/$/, "");
|
|
223
|
+
const transportSecurity = evaluateTransportSecurity(apiHost, {
|
|
220
224
|
allowInsecureHttp: cliArgs.insecure === true,
|
|
221
225
|
label: "DB90 API host",
|
|
222
226
|
});
|
|
@@ -228,10 +232,6 @@ export async function runInit(cliArgs, deps) {
|
|
|
228
232
|
runtime.error(`Warning: ${transportSecurity.warning}`);
|
|
229
233
|
}
|
|
230
234
|
const kcIssuer = (cliArgs.keycloakUrl ?? runtime.defaultKeycloakIssuer()).trim();
|
|
231
|
-
if (!kcIssuer) {
|
|
232
|
-
runtime.error("Error: Keycloak issuer is not configured. Pass --keycloak-url or set KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER.");
|
|
233
|
-
return 1;
|
|
234
|
-
}
|
|
235
235
|
if (cliArgs.toolName !== undefined && !["claude_code", "cursor"].includes(cliArgs.toolName)) {
|
|
236
236
|
runtime.error("Error: --tool-name must be one of: claude_code, cursor.");
|
|
237
237
|
return 1;
|
|
@@ -244,12 +244,12 @@ export async function runInit(cliArgs, deps) {
|
|
|
244
244
|
const fromFlag = cliArgs.organizationId?.trim();
|
|
245
245
|
const fromEnv = process.env["DB90_ORGANIZATION_ID"]?.trim();
|
|
246
246
|
const exchangeOrganizationId = fromFlag || fromEnv;
|
|
247
|
-
if (exchangeOrganizationId && !
|
|
247
|
+
if (exchangeOrganizationId && !isValidOrganizationUuid(exchangeOrganizationId)) {
|
|
248
248
|
runtime.error("Error: --organization-id / DB90_ORGANIZATION_ID must be a valid UUID (RFC 4122, version 1–5, variant per DB90 API).");
|
|
249
249
|
return 1;
|
|
250
250
|
}
|
|
251
251
|
const result = await runtime.loginAndPersistCredentials({
|
|
252
|
-
|
|
252
|
+
apiHost,
|
|
253
253
|
keycloakIssuer: kcIssuer,
|
|
254
254
|
tools: provisionTools.length > 1 ? provisionTools : undefined,
|
|
255
255
|
toolName: provisionTools.length === 1
|
|
@@ -261,6 +261,7 @@ export async function runInit(cliArgs, deps) {
|
|
|
261
261
|
appDir: runtime.getAppDir(),
|
|
262
262
|
exchangeOrganizationId: exchangeOrganizationId || undefined,
|
|
263
263
|
allowInsecureHttp: cliArgs.insecure === true,
|
|
264
|
+
force: cliArgs.force === true,
|
|
264
265
|
onSecurityWarning: (message) => {
|
|
265
266
|
runtime.error(`Warning: ${message}`);
|
|
266
267
|
},
|
|
@@ -272,7 +273,12 @@ export async function runInit(cliArgs, deps) {
|
|
|
272
273
|
runtime.error(`Auth failed: ${result.error}`);
|
|
273
274
|
return 1;
|
|
274
275
|
}
|
|
275
|
-
|
|
276
|
+
if ("alreadyAuthenticated" in result) {
|
|
277
|
+
runtime.log(`Already authenticated (organization ${result.organizationId ?? "unknown"}). Pass --force to re-authenticate.`);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
runtime.log(`Credentials saved (organization ${result.organizationId}).`);
|
|
281
|
+
}
|
|
276
282
|
if (cliArgs.hooks) {
|
|
277
283
|
const appDir = runtime.getAppDir();
|
|
278
284
|
const thisFile = nodeFileURLToPath(import.meta.url);
|
|
@@ -318,6 +324,28 @@ export async function runInit(cliArgs, deps) {
|
|
|
318
324
|
else {
|
|
319
325
|
runtime.log("Skipped Claude Code MCP auto-install (--tool-name cursor only).");
|
|
320
326
|
}
|
|
327
|
+
const shouldInstallCursor = provisionTools.includes("cursor");
|
|
328
|
+
if (shouldInstallCursor) {
|
|
329
|
+
const cursorInstallResult = runtime.installCursorUserMcp({ force: cliArgs.force === true });
|
|
330
|
+
switch (cursorInstallResult.kind) {
|
|
331
|
+
case "already-configured":
|
|
332
|
+
runtime.log("Cursor MCP: aixle-insights server is already configured for your user.");
|
|
333
|
+
break;
|
|
334
|
+
case "installed":
|
|
335
|
+
runtime.log("Cursor MCP: added aixle-insights server to ~/.cursor/mcp.json.");
|
|
336
|
+
break;
|
|
337
|
+
case "requires-force":
|
|
338
|
+
runtime.error(cursorInstallResult.detail);
|
|
339
|
+
return 1;
|
|
340
|
+
case "error":
|
|
341
|
+
runtime.error(`Cursor MCP install failed: ${cursorInstallResult.message}`);
|
|
342
|
+
return 1;
|
|
343
|
+
}
|
|
344
|
+
runtime.log("Restart Cursor to activate the aixle-insights MCP server.");
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
runtime.log("Skipped Cursor MCP auto-install (--tool-name claude_code only).");
|
|
348
|
+
}
|
|
321
349
|
return 0;
|
|
322
350
|
}
|
|
323
351
|
export async function runOnce(deps, options) {
|
|
@@ -334,6 +362,8 @@ export async function runOnce(deps, options) {
|
|
|
334
362
|
};
|
|
335
363
|
const creds = await runtime.loadCredentials();
|
|
336
364
|
if (!creds || !credentialsHaveAnyToken(creds)) {
|
|
365
|
+
// File-only: runOnce prints a user-facing error below, so mirroring this
|
|
366
|
+
// structured warn would just duplicate the message on stderr.
|
|
337
367
|
mcpLog.warn("credential_validation_failed", { source: "cli_once", reason: "missing_credentials" }, false);
|
|
338
368
|
runtime.error("Error: no Aixle Insights credentials. Run `aixle-insights init` first (dual-tool auth is the default).");
|
|
339
369
|
return 1;
|
|
@@ -348,7 +378,7 @@ export async function runOnce(deps, options) {
|
|
|
348
378
|
let projectId = null;
|
|
349
379
|
let projectIdSource = "none";
|
|
350
380
|
if (lookupToken) {
|
|
351
|
-
const resolution = await runtime.resolveProjectId(undefined, undefined, creds.host, lookupToken, false);
|
|
381
|
+
const resolution = await runtime.resolveProjectId(undefined, undefined, creds.host, lookupToken, false, creds.insecureHttpAllowed === true);
|
|
352
382
|
projectId = resolution.projectId;
|
|
353
383
|
projectIdSource = resolution.source;
|
|
354
384
|
mcpLog.info("project_attribution_resolved", { project_id: resolution.projectId, source: resolution.source }, false);
|
|
@@ -408,6 +438,24 @@ async function main() {
|
|
|
408
438
|
}
|
|
409
439
|
return;
|
|
410
440
|
}
|
|
441
|
+
case "uninstall-cursor-mcp": {
|
|
442
|
+
const result = uninstallCursorUserMcp();
|
|
443
|
+
switch (result.kind) {
|
|
444
|
+
case "restored":
|
|
445
|
+
console.log(`Cursor MCP entry removed; ~/.cursor/mcp.json restored from ${result.backupPath}.`);
|
|
446
|
+
break;
|
|
447
|
+
case "removed":
|
|
448
|
+
console.log("Cursor MCP entry removed from ~/.cursor/mcp.json.");
|
|
449
|
+
break;
|
|
450
|
+
case "noop":
|
|
451
|
+
console.log("No aixle-insights entry found in ~/.cursor/mcp.json — nothing to uninstall.");
|
|
452
|
+
break;
|
|
453
|
+
case "error":
|
|
454
|
+
console.error(`Cursor MCP uninstall failed: ${result.message}`);
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
411
459
|
case "verify-hooks": {
|
|
412
460
|
const report = verifyHooksConfig(getAppDir());
|
|
413
461
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ProjectResolution } from "./lib/index.js";
|
|
2
2
|
import type { State } from "./state.js";
|
|
3
|
-
import { type
|
|
3
|
+
import { type CursorPayload, type CursorTranscriptTurn, type PricingConfig } from "./readers/cursor.js";
|
|
4
4
|
export interface CursorSliceGroup {
|
|
5
5
|
key: string;
|
|
6
6
|
label: string;
|
|
7
|
-
payloads:
|
|
7
|
+
payloads: CursorPayload[];
|
|
8
8
|
}
|
|
9
9
|
export interface PrepareCursorSliceGroupsOptions {
|
|
10
10
|
stateBefore: State;
|
|
@@ -14,6 +14,7 @@ export interface PrepareCursorSliceGroupsOptions {
|
|
|
14
14
|
host?: string;
|
|
15
15
|
token?: string;
|
|
16
16
|
projectLookupToken?: string | null;
|
|
17
|
+
allowInsecureHttp?: boolean;
|
|
17
18
|
verbose?: boolean;
|
|
18
19
|
cursorBaseDir?: string;
|
|
19
20
|
cursorTranscriptProjectDirs?: string[];
|
|
@@ -46,7 +47,7 @@ export interface CollectLocalCursorPayloadsOptions {
|
|
|
46
47
|
stateBefore?: State;
|
|
47
48
|
}
|
|
48
49
|
export interface CollectedCursorPayloads {
|
|
49
|
-
payloads:
|
|
50
|
+
payloads: CursorPayload[];
|
|
50
51
|
counts: PreparedCursorSliceGroups["counts"];
|
|
51
52
|
}
|
|
52
53
|
/**
|
|
@@ -6,7 +6,7 @@ import { readEvents as readCursorEvents, readDailyStatsWithDedupe, readRecentCom
|
|
|
6
6
|
* Read local Cursor stores, apply watermarks/dedupe, and group payloads for sync posting.
|
|
7
7
|
*/
|
|
8
8
|
export async function prepareCursorSliceGroups(options) {
|
|
9
|
-
const { stateBefore, fullScan = false, projectId = null, projectIdSource, host, token, projectLookupToken, verbose = false, cursorBaseDir, cursorTranscriptProjectDirs, cursorPricing = DEFAULT_CURSOR_PRICING, } = options;
|
|
9
|
+
const { stateBefore, fullScan = false, projectId = null, projectIdSource, host, token, projectLookupToken, allowInsecureHttp, verbose = false, cursorBaseDir, cursorTranscriptProjectDirs, cursorPricing = DEFAULT_CURSOR_PRICING, } = options;
|
|
10
10
|
const useCommitHashDedup = !fullScan;
|
|
11
11
|
const eventsSince = fullScan
|
|
12
12
|
? null
|
|
@@ -22,7 +22,9 @@ export async function prepareCursorSliceGroups(options) {
|
|
|
22
22
|
console.log("[verbose][cursor] Full scan — ignoring saved watermarks and commit hash dedupe");
|
|
23
23
|
}
|
|
24
24
|
const baseDir = cursorBaseDir;
|
|
25
|
-
const
|
|
25
|
+
const activeModelResolution = readCursorActiveModel(baseDir);
|
|
26
|
+
const activeModel = activeModelResolution.model ?? undefined;
|
|
27
|
+
const modelResolution = activeModelResolution.source;
|
|
26
28
|
const transcriptTurns = await readCursorTranscriptSessions(baseDir, cursorTranscriptProjectDirs, verbose);
|
|
27
29
|
const rawEvents = readCursorEvents(eventsSince, baseDir, verbose);
|
|
28
30
|
const { raw: dailyStatsRaw, deduped: dailyStats } = readDailyStatsWithDedupe(dailyStatsSince, baseDir, verbose);
|
|
@@ -41,7 +43,7 @@ export async function prepareCursorSliceGroups(options) {
|
|
|
41
43
|
return known.contentHash !== turn.contentHash;
|
|
42
44
|
return known.fileSize !== turn.fileSize;
|
|
43
45
|
})
|
|
44
|
-
.map((turn) => mapCursorTranscriptTurn(turn, projectIdOpt, cursorPricing, activeModel))
|
|
46
|
+
.map((turn) => mapCursorTranscriptTurn(turn, projectIdOpt, cursorPricing, activeModel, modelResolution))
|
|
45
47
|
.sort((a, b) => a.occurred_at.localeCompare(b.occurred_at));
|
|
46
48
|
const skippedTranscriptCount = transcriptTurns.length - transcriptPayloads.length;
|
|
47
49
|
const transcriptModeEnabled = transcriptTurnsById.size > 0;
|
|
@@ -49,7 +51,7 @@ export async function prepareCursorSliceGroups(options) {
|
|
|
49
51
|
.map(({ row, workspacePath }) => mapCursorEvent(row, workspacePath, projectIdOpt, cursorPricing))
|
|
50
52
|
.filter((e) => e !== null);
|
|
51
53
|
const allMappedFromStats = dailyStats
|
|
52
|
-
.flatMap((entry) => mapDailyStats(entry, projectIdOpt, cursorPricing, activeModel));
|
|
54
|
+
.flatMap((entry) => mapDailyStats(entry, projectIdOpt, cursorPricing, activeModel, modelResolution));
|
|
53
55
|
// When transcripts are present they cover the same chat activity — suppress the daily aggregates
|
|
54
56
|
// to prevent double-counting. Logged at info level in sync.ts via counts.suppressedComposer.
|
|
55
57
|
const mappedFromEvents = allMappedFromEvents.filter((payload) => !transcriptModeEnabled || payload.event_type !== "chat");
|
|
@@ -59,7 +61,7 @@ export async function prepareCursorSliceGroups(options) {
|
|
|
59
61
|
allMappedFromStats.filter((p) => p.event_type === "chat").length
|
|
60
62
|
: 0;
|
|
61
63
|
let mappedFromCommits = recentCommitSnapshots
|
|
62
|
-
.map((snapshot) => mapRecentCommit(snapshot, projectIdOpt, cursorPricing, activeModel))
|
|
64
|
+
.map((snapshot) => mapRecentCommit(snapshot, projectIdOpt, cursorPricing, activeModel, modelResolution))
|
|
63
65
|
.filter((payload) => payload !== null)
|
|
64
66
|
.sort((a, b) => a.occurred_at.localeCompare(b.occurred_at));
|
|
65
67
|
if (useCommitHashDedup) {
|
|
@@ -72,6 +74,7 @@ export async function prepareCursorSliceGroups(options) {
|
|
|
72
74
|
projectIdSource,
|
|
73
75
|
host,
|
|
74
76
|
token: lookupToken,
|
|
77
|
+
allowInsecureHttp,
|
|
75
78
|
verbose,
|
|
76
79
|
});
|
|
77
80
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { State } from "./state.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { CursorPayload } from "./readers/cursor.js";
|
|
3
3
|
/** Cursor SQLite watermark checkpoints — never collide with Claude `claude_code:*` session keys. */
|
|
4
4
|
export declare const CURSOR_WATERMARK_KEY: "cursor:watermark";
|
|
5
5
|
export declare const CURSOR_EVENTS_WATERMARK_KEY: "cursor:events_watermark";
|
|
@@ -9,4 +9,4 @@ export declare const CURSOR_TRANSCRIPT_TURN_PREFIX: "cursor:transcript_turn:";
|
|
|
9
9
|
export declare function cursorTranscriptTurnStateKey(turnId: string): string;
|
|
10
10
|
export declare function cursorWatermarkDate(state: Pick<State, "sessions">, ...keys: string[]): Date | null;
|
|
11
11
|
/** Skip recent-commit payloads whose hash was already successfully POSTed. */
|
|
12
|
-
export declare function filterRecentCommitsByHashDedup(payloads:
|
|
12
|
+
export declare function filterRecentCommitsByHashDedup(payloads: CursorPayload[], lastRecentCommitHashes?: string[]): CursorPayload[];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CursorPayload } from "./readers/cursor.js";
|
|
2
2
|
/** Ingest paths for Cursor payloads emitted by telemetry-mcp. */
|
|
3
3
|
export type CursorIngestPath = "daily_tab" | "daily_composer" | "legacy_request" | "recent_commit" | "mcp_transcript" | "cursor_hook";
|
|
4
4
|
export interface PayloadValidationResult {
|
|
@@ -6,12 +6,12 @@ export interface PayloadValidationResult {
|
|
|
6
6
|
errors: string[];
|
|
7
7
|
path: CursorIngestPath | "unknown";
|
|
8
8
|
}
|
|
9
|
-
export declare function inferIngestPath(payload:
|
|
10
|
-
export declare function validateCursorPayload(payload:
|
|
9
|
+
export declare function inferIngestPath(payload: CursorPayload): CursorIngestPath | "unknown";
|
|
10
|
+
export declare function validateCursorPayload(payload: CursorPayload): PayloadValidationResult;
|
|
11
11
|
export interface DryRunMatrixRow {
|
|
12
12
|
path: CursorIngestPath | "unknown";
|
|
13
13
|
count: number;
|
|
14
14
|
sample_occurred_at: string | null;
|
|
15
15
|
}
|
|
16
|
-
export declare function summarizeDryRunMatrix(payloads:
|
|
17
|
-
export declare function printCursorDryRunValidationReport(payloads:
|
|
16
|
+
export declare function summarizeDryRunMatrix(payloads: CursorPayload[]): DryRunMatrixRow[];
|
|
17
|
+
export declare function printCursorDryRunValidationReport(payloads: CursorPayload[]): boolean;
|
|
@@ -19,6 +19,7 @@ const METADATA_BASE_KEYS = new Set([
|
|
|
19
19
|
"cost_model",
|
|
20
20
|
"scannable",
|
|
21
21
|
"risk_level",
|
|
22
|
+
"model_resolution",
|
|
22
23
|
]);
|
|
23
24
|
const METADATA_COMMIT_KEYS = new Set([
|
|
24
25
|
...METADATA_BASE_KEYS,
|
|
@@ -42,6 +43,7 @@ const METADATA_TRANSCRIPT_KEYS = new Set([
|
|
|
42
43
|
"composer_name",
|
|
43
44
|
"prompt_text",
|
|
44
45
|
"assistant_text",
|
|
46
|
+
"model_resolution",
|
|
45
47
|
]);
|
|
46
48
|
const METADATA_HOOK_KEYS = new Set([
|
|
47
49
|
"cursor_session_id",
|
|
@@ -136,6 +138,10 @@ export function validateCursorPayload(payload) {
|
|
|
136
138
|
if (typeof meta.workspace !== "string" || meta.workspace.length === 0) {
|
|
137
139
|
errors.push("metadata.workspace must be a non-empty string");
|
|
138
140
|
}
|
|
141
|
+
if (meta.model_resolution !== undefined &&
|
|
142
|
+
!["settings_json", "state_vscdb", "unresolved"].includes(meta.model_resolution)) {
|
|
143
|
+
errors.push('metadata.model_resolution must be "settings_json", "state_vscdb", or "unresolved" when present');
|
|
144
|
+
}
|
|
139
145
|
if (path !== "mcp_transcript" && path !== "cursor_hook") {
|
|
140
146
|
if (meta.workspace_scope !== "global" && meta.workspace_scope !== "workspace") {
|
|
141
147
|
errors.push('metadata.workspace_scope must be "global" or "workspace"');
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
export type CursorModelResolutionSource = "settings_json" | "state_vscdb" | "unresolved";
|
|
2
|
+
export interface CursorActiveModelResolution {
|
|
3
|
+
model: string | null;
|
|
4
|
+
source: CursorModelResolutionSource;
|
|
5
|
+
}
|
|
1
6
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
7
|
+
* Resolves Cursor's active model, chaining settings.json (pre-1.6 location) then
|
|
8
|
+
* state.vscdb (1.6+ location). Reports which source supplied the model, or "unresolved"
|
|
9
|
+
* when neither has it, so downstream payload metadata can record where the tool looked.
|
|
5
10
|
*/
|
|
6
|
-
export declare function readCursorActiveModel(baseDir?: string):
|
|
11
|
+
export declare function readCursorActiveModel(baseDir?: string): CursorActiveModelResolution;
|
package/dist/cursor-settings.js
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { cursorUserDir } from "./readers/cursor.js";
|
|
4
|
-
|
|
5
|
-
// generic fallbacks. "model" is last to avoid capturing unrelated workspace
|
|
6
|
-
// settings that happen to have a "model" key.
|
|
4
|
+
import { openCursorSqliteReadonly } from "./readers/cursor-sqlite.js";
|
|
7
5
|
const SETTINGS_MODEL_KEYS = [
|
|
8
6
|
"cursor.aiModel",
|
|
9
7
|
"aiModel",
|
|
10
8
|
"cursor.general.preferredModel",
|
|
11
9
|
"model",
|
|
12
10
|
];
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* Returns null on any error (file absent, unreadable, no matching key).
|
|
16
|
-
* Never throws.
|
|
17
|
-
*/
|
|
18
|
-
export function readCursorActiveModel(baseDir) {
|
|
19
|
-
const settingsPath = join(baseDir ?? cursorUserDir(), "settings.json");
|
|
11
|
+
function readModelFromSettingsJson(dir) {
|
|
12
|
+
const settingsPath = join(dir, "settings.json");
|
|
20
13
|
try {
|
|
21
14
|
if (!existsSync(settingsPath))
|
|
22
15
|
return null;
|
|
@@ -36,3 +29,80 @@ export function readCursorActiveModel(baseDir) {
|
|
|
36
29
|
return null;
|
|
37
30
|
}
|
|
38
31
|
}
|
|
32
|
+
function safeParseJson(raw) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(raw);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function isNonEmptyString(v) {
|
|
41
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Reads the active model from Cursor's global state.vscdb (Cursor 1.6+), where settings.json
|
|
45
|
+
* no longer carries it. The model lives in the cursorDiskKV table (verified against a real
|
|
46
|
+
* Cursor install, 2026-07-10 — NOT ItemTable.aiSettings/featureModelConfigs, which don't exist
|
|
47
|
+
* on current Cursor versions), one row per conversation keyed composerData:<composerId>. The
|
|
48
|
+
* table is UNIQUE ON CONFLICT REPLACE, so every update to a composer re-inserts it with a fresh
|
|
49
|
+
* (higher) rowid — ORDER BY rowid DESC LIMIT 1 gives the most recently touched composer without
|
|
50
|
+
* needing to parse and rank every row's timestamp. Opens the DB only via openCursorSqliteReadonly
|
|
51
|
+
* (read-only, root-contained) — never a raw new Database() call.
|
|
52
|
+
*/
|
|
53
|
+
function readCursorActiveModelFromStateDb(baseDir) {
|
|
54
|
+
const dbPath = join(baseDir, "globalStorage", "state.vscdb");
|
|
55
|
+
const opened = openCursorSqliteReadonly(dbPath, { rootDir: baseDir });
|
|
56
|
+
if (!opened.ok)
|
|
57
|
+
return null;
|
|
58
|
+
const { db } = opened;
|
|
59
|
+
try {
|
|
60
|
+
const hasTable = db
|
|
61
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
|
|
62
|
+
.get();
|
|
63
|
+
if (!hasTable)
|
|
64
|
+
return null;
|
|
65
|
+
const row = db
|
|
66
|
+
.prepare("SELECT value FROM cursorDiskKV WHERE key LIKE 'composerData:%' ORDER BY rowid DESC LIMIT 1")
|
|
67
|
+
.get();
|
|
68
|
+
if (!row)
|
|
69
|
+
return null;
|
|
70
|
+
const parsed = safeParseJson(row.value);
|
|
71
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
72
|
+
return null;
|
|
73
|
+
const modelConfig = parsed.modelConfig;
|
|
74
|
+
if (typeof modelConfig !== "object" || modelConfig === null)
|
|
75
|
+
return null;
|
|
76
|
+
const mc = modelConfig;
|
|
77
|
+
if (isNonEmptyString(mc.modelName))
|
|
78
|
+
return mc.modelName.trim();
|
|
79
|
+
const selectedModels = mc.selectedModels;
|
|
80
|
+
if (Array.isArray(selectedModels) && selectedModels.length > 0) {
|
|
81
|
+
const first = selectedModels[0];
|
|
82
|
+
if (isNonEmptyString(first?.modelId))
|
|
83
|
+
return first.modelId.trim();
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
db.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolves Cursor's active model, chaining settings.json (pre-1.6 location) then
|
|
96
|
+
* state.vscdb (1.6+ location). Reports which source supplied the model, or "unresolved"
|
|
97
|
+
* when neither has it, so downstream payload metadata can record where the tool looked.
|
|
98
|
+
*/
|
|
99
|
+
export function readCursorActiveModel(baseDir) {
|
|
100
|
+
const dir = baseDir ?? cursorUserDir();
|
|
101
|
+
const fromSettings = readModelFromSettingsJson(dir);
|
|
102
|
+
if (fromSettings)
|
|
103
|
+
return { model: fromSettings, source: "settings_json" };
|
|
104
|
+
const fromStateDb = readCursorActiveModelFromStateDb(dir);
|
|
105
|
+
if (fromStateDb)
|
|
106
|
+
return { model: fromStateDb, source: "state_vscdb" };
|
|
107
|
+
return { model: null, source: "unresolved" };
|
|
108
|
+
}
|
package/dist/health.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface HealthSnapshot {
|
|
|
16
16
|
hooks_queue_depth: number;
|
|
17
17
|
/** Best-effort merge of credential-scoped `mcp_operator` (latest `last_sync_at`). */
|
|
18
18
|
persisted: McpOperatorState | null;
|
|
19
|
+
/** True only when buildHealthSnapshot caught an unexpected error building the snapshot. */
|
|
20
|
+
snapshot_failed?: boolean;
|
|
19
21
|
/** In-process telemetry (same fields as `getSyncTelemetry`). */
|
|
20
22
|
process: {
|
|
21
23
|
last_sync_at: string | null;
|
|
@@ -26,6 +28,6 @@ export interface HealthSnapshot {
|
|
|
26
28
|
/** Pick the freshest persisted operator block by `last_sync_at` lexicographic (ISO-safe). */
|
|
27
29
|
export declare function mergePersistedOperators(snapshots: McpOperatorState[]): McpOperatorState | null;
|
|
28
30
|
export declare function buildHealthSnapshot(): Promise<HealthSnapshot>;
|
|
29
|
-
/** MCP `
|
|
31
|
+
/** MCP `aixle_insights_status` JSON — single source shared with CLI health. */
|
|
30
32
|
export declare function healthSnapshotToStatusPayload(snapshot: HealthSnapshot): Record<string, unknown>;
|
|
31
33
|
export declare function formatHealthForCli(snapshot: HealthSnapshot): string;
|
package/dist/health.js
CHANGED
|
@@ -122,6 +122,7 @@ export async function buildHealthSnapshot() {
|
|
|
122
122
|
hooks_installed: hooksReport.hooks_json_installed,
|
|
123
123
|
hooks_queue_depth: hooksReport.queue_depth,
|
|
124
124
|
persisted: null,
|
|
125
|
+
snapshot_failed: true,
|
|
125
126
|
process: {
|
|
126
127
|
last_sync_at: telemetry.lastSyncAt,
|
|
127
128
|
last_result: telemetry.lastResult,
|
|
@@ -133,18 +134,25 @@ export async function buildHealthSnapshot() {
|
|
|
133
134
|
};
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
|
-
/** MCP `
|
|
137
|
+
/** MCP `aixle_insights_status` JSON — single source shared with CLI health. */
|
|
137
138
|
export function healthSnapshotToStatusPayload(snapshot) {
|
|
138
139
|
const proc = snapshot.process;
|
|
139
140
|
const pers = snapshot.persisted;
|
|
140
141
|
const lastSyncAt = proc.last_sync_at ?? pers?.last_sync_at ?? null;
|
|
141
142
|
const lastResult = proc.last_result ?? snapshotToResult(pers?.last_result ?? null);
|
|
142
143
|
const errors = mergeErrors(proc.recent_errors, pers?.recent_errors ?? []);
|
|
144
|
+
const needsInit = !snapshot.authenticated && snapshot.snapshot_failed !== true;
|
|
145
|
+
const onboardingMessage = needsInit
|
|
146
|
+
? "aixle-insights is installed but not connected. Run `aixle-insights init` to start tracking — " +
|
|
147
|
+
"activity since install is not lost; the first sync after init backfills all of it."
|
|
148
|
+
: null;
|
|
143
149
|
return {
|
|
144
150
|
authenticated: snapshot.authenticated,
|
|
145
151
|
configured: snapshot.configured,
|
|
146
152
|
host: snapshot.host,
|
|
147
153
|
ingest_tools: snapshot.ingest_tools,
|
|
154
|
+
needs_init: needsInit,
|
|
155
|
+
onboarding_message: onboardingMessage,
|
|
148
156
|
last_sync_at: lastSyncAt,
|
|
149
157
|
last_result: lastResult,
|
|
150
158
|
sessions_synced: lastResult?.sent ?? 0,
|
|
@@ -191,5 +199,9 @@ export function formatHealthForCli(snapshot) {
|
|
|
191
199
|
else {
|
|
192
200
|
lines.push(" (none)");
|
|
193
201
|
}
|
|
202
|
+
if (typeof sta["onboarding_message"] === "string") {
|
|
203
|
+
lines.push("");
|
|
204
|
+
lines.push(sta["onboarding_message"]);
|
|
205
|
+
}
|
|
194
206
|
return lines.join("\n");
|
|
195
207
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HookLogEvent } from "./hooks-config.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { CursorPayload } from "../readers/cursor.js";
|
|
3
3
|
export { HookLogEvent };
|
|
4
4
|
/** State key prefix for hook event dedup in state.sessions. */
|
|
5
5
|
export declare const CURSOR_HOOK_STATE_PREFIX: "cursor:hook:";
|
|
@@ -16,7 +16,7 @@ export declare function shouldIngestHookEvent(event: HookLogEvent, verbose?: boo
|
|
|
16
16
|
/** Warn-only version gate — Cursor builds can have unusual version strings. */
|
|
17
17
|
export declare function warnOnCursorVersion(event: HookLogEvent, verbose: boolean): void;
|
|
18
18
|
/**
|
|
19
|
-
* Map a validated hook event to the
|
|
19
|
+
* Map a validated hook event to the CursorPayload contract.
|
|
20
20
|
* Call shouldIngestHookEvent() before this — it does not re-validate.
|
|
21
21
|
*/
|
|
22
|
-
export declare function mapHookEventToPayload(event: HookLogEvent, projectId?: string | null):
|
|
22
|
+
export declare function mapHookEventToPayload(event: HookLogEvent, projectId?: string | null): CursorPayload;
|
|
@@ -47,7 +47,7 @@ export function warnOnCursorVersion(event, verbose) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
-
* Map a validated hook event to the
|
|
50
|
+
* Map a validated hook event to the CursorPayload contract.
|
|
51
51
|
* Call shouldIngestHookEvent() before this — it does not re-validate.
|
|
52
52
|
*/
|
|
53
53
|
export function mapHookEventToPayload(event, projectId) {
|
|
@@ -8,6 +8,8 @@ export interface ProcessHooksQueueParams {
|
|
|
8
8
|
state: State;
|
|
9
9
|
host: string;
|
|
10
10
|
token: string;
|
|
11
|
+
/** Mirrors StoredCredentials.insecureHttpAllowed — set when `init --insecure` was used for this host. */
|
|
12
|
+
allowInsecureHttp?: boolean;
|
|
11
13
|
/** Called when a 429 is received. */
|
|
12
14
|
on429: (retryAfter: number, quotaExceeded: boolean) => void;
|
|
13
15
|
/** If true, skip events already in state.sessions. */
|
|
@@ -6,9 +6,16 @@ import { markSessionSent } from "../state.js";
|
|
|
6
6
|
import { postEvent } from "../client.js";
|
|
7
7
|
import { mcpLog } from "../log.js";
|
|
8
8
|
import { shouldIngestHookEvent, mapHookEventToPayload, hookDedupeKey, warnOnCursorVersion, CURSOR_HOOK_STATE_PREFIX, } from "./cursor-hooks-mapper.js";
|
|
9
|
+
import { isRepoPathWithinRoot, normalizeRepoPathCandidate } from "../lib/repo-path-safety.js";
|
|
9
10
|
export { CURSOR_HOOK_STATE_PREFIX };
|
|
11
|
+
/**
|
|
12
|
+
* `workspace` is `workspace_roots[0]` from the on-disk hooks queue — an
|
|
13
|
+
* arbitrary JSON string. A plain prefix match would accept
|
|
14
|
+
* `<scopeDir>/../../elsewhere` (DB90DV-547).
|
|
15
|
+
*/
|
|
10
16
|
function isUnderScopeDir(workspace, scopeDir) {
|
|
11
|
-
|
|
17
|
+
const normalized = normalizeRepoPathCandidate(workspace);
|
|
18
|
+
return normalized !== null && isRepoPathWithinRoot(normalized, scopeDir);
|
|
12
19
|
}
|
|
13
20
|
/**
|
|
14
21
|
* The forwarder redacts the home directory to "~" in workspace_roots for
|
|
@@ -47,7 +54,7 @@ function rewriteQueueKeepingLines(queuePath, allLines, keepIndices) {
|
|
|
47
54
|
* Partial failure: lines that succeeded are removed; lines that failed stay.
|
|
48
55
|
*/
|
|
49
56
|
export async function processHooksQueue(params) {
|
|
50
|
-
const { queuePath, scopeDir, host, token, on429, skipSeen = true, resolveProjectId, verbose = false, } = params;
|
|
57
|
+
const { queuePath, scopeDir, host, token, allowInsecureHttp = false, on429, skipSeen = true, resolveProjectId, verbose = false, } = params;
|
|
51
58
|
if (!existsSync(queuePath)) {
|
|
52
59
|
return { sent: 0, failed: 0, skipped: 0, state: params.state };
|
|
53
60
|
}
|
|
@@ -97,7 +104,7 @@ export async function processHooksQueue(params) {
|
|
|
97
104
|
projectId = await resolveProjectId(workspace);
|
|
98
105
|
}
|
|
99
106
|
const payload = mapHookEventToPayload(event, projectId);
|
|
100
|
-
const ok = await postEvent(payload, host, token, { on429 });
|
|
107
|
+
const ok = await postEvent(payload, host, token, { on429, allowInsecureHttp });
|
|
101
108
|
if (ok) {
|
|
102
109
|
totalSent++;
|
|
103
110
|
stateMut = markSessionSent(stateMut, dedupeKey, 0);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type InstallResult } from "./claude.js";
|
|
2
|
+
export interface InstallCursorUserMcpOptions {
|
|
3
|
+
/** Tests: full path to the Cursor MCP config file (default ~/.cursor/mcp.json). */
|
|
4
|
+
cursorConfigPath?: string;
|
|
5
|
+
force?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export type UninstallResult = {
|
|
8
|
+
kind: "restored";
|
|
9
|
+
backupPath: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "removed";
|
|
12
|
+
} | {
|
|
13
|
+
kind: "noop";
|
|
14
|
+
} | {
|
|
15
|
+
kind: "error";
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function defaultCursorUserConfigPath(): string;
|
|
19
|
+
/**
|
|
20
|
+
* Merges top-level `mcpServers.aixle-insights` into ~/.cursor/mcp.json (or overridden path).
|
|
21
|
+
* Preserves all other keys and MCP server entries. Backs up the existing file
|
|
22
|
+
* (once) before the first write, and removes legacy "db90"/"insights" keys so
|
|
23
|
+
* Cursor does not spawn duplicate aixle-insights servers.
|
|
24
|
+
*/
|
|
25
|
+
export declare function installCursorUserMcp(options?: InstallCursorUserMcpOptions): InstallResult;
|
|
26
|
+
/**
|
|
27
|
+
* Removes the aixle-insights entry from ~/.cursor/mcp.json without disturbing any
|
|
28
|
+
* other server entry — including ones the user or Cursor added after install.
|
|
29
|
+
* Works on the CURRENT file (never a full-file revert to the backup). If a
|
|
30
|
+
* pre-install backup exists, the user's ORIGINAL aixle-insights entry (if any)
|
|
31
|
+
* is restored from it; otherwise our key is simply removed. The backup file is
|
|
32
|
+
* cleaned up either way.
|
|
33
|
+
*/
|
|
34
|
+
export declare function uninstallCursorUserMcp(options?: InstallCursorUserMcpOptions): UninstallResult;
|