@rigour-labs/core 5.3.3 → 5.5.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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Parse and normalize Cursor Admin API filtered usage events.
3
+ */
4
+ import { createHash } from 'crypto';
5
+ export const CURSOR_ADMIN_API_SOURCE = 'cursor-admin-api';
6
+ function isRecord(value) {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+ export function isCursorAdminApiEvent(value) {
10
+ if (!isRecord(value))
11
+ return false;
12
+ return value.tokenUsage !== undefined
13
+ || value.chargedCents !== undefined
14
+ || (value.timestamp !== undefined && value.model !== undefined);
15
+ }
16
+ function readNumber(value, fallback = 0) {
17
+ if (typeof value === 'number' && Number.isFinite(value))
18
+ return value;
19
+ if (typeof value === 'string' && value.trim() !== '') {
20
+ const parsed = Number(value);
21
+ if (Number.isFinite(parsed))
22
+ return parsed;
23
+ }
24
+ return fallback;
25
+ }
26
+ function readString(value) {
27
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
28
+ }
29
+ /**
30
+ * Cursor reports costs in cents (`chargedCents`, `tokenUsage.totalCents`).
31
+ */
32
+ export function centsToUsd(cents) {
33
+ return parseFloat((cents / 100).toFixed(6));
34
+ }
35
+ export function buildCursorEventFingerprint(event) {
36
+ const tokenUsage = isRecord(event.tokenUsage) ? event.tokenUsage : {};
37
+ const payload = [
38
+ readString(event.timestamp) ?? '',
39
+ readString(event.userEmail) ?? '',
40
+ readString(event.conversationId) ?? '',
41
+ readString(event.model) ?? '',
42
+ readNumber(event.chargedCents),
43
+ readNumber(tokenUsage.inputTokens),
44
+ readNumber(tokenUsage.outputTokens),
45
+ readNumber(tokenUsage.cacheReadTokens),
46
+ readNumber(tokenUsage.cacheWriteTokens),
47
+ ].join('|');
48
+ return createHash('sha256').update(payload).digest('hex').slice(0, 16);
49
+ }
50
+ export function normalizeCursorUsageEvent(raw) {
51
+ if (!isRecord(raw))
52
+ return null;
53
+ const model = readString(raw.model) ?? 'unknown-model';
54
+ const timestamp = readString(raw.timestamp);
55
+ const conversationId = readString(raw.conversationId);
56
+ const tokenUsage = isRecord(raw.tokenUsage) ? raw.tokenUsage : undefined;
57
+ const inputTokens = tokenUsage ? readNumber(tokenUsage.inputTokens) : 0;
58
+ const outputTokens = tokenUsage ? readNumber(tokenUsage.outputTokens) : 0;
59
+ const cacheReadTokens = tokenUsage ? readNumber(tokenUsage.cacheReadTokens) : 0;
60
+ const cacheWriteTokens = tokenUsage ? readNumber(tokenUsage.cacheWriteTokens) : 0;
61
+ const chargedCents = readNumber(raw.chargedCents);
62
+ const tokenTotalCents = tokenUsage ? readNumber(tokenUsage.totalCents) : 0;
63
+ const observedCostUsd = chargedCents > 0
64
+ ? centsToUsd(chargedCents)
65
+ : tokenTotalCents > 0
66
+ ? centsToUsd(tokenTotalCents)
67
+ : 0;
68
+ const fingerprint = buildCursorEventFingerprint(raw);
69
+ const createdAt = timestamp ? readNumber(timestamp, Date.now()) : Date.now();
70
+ return {
71
+ id: `cursor-api-${fingerprint}`,
72
+ usage: {
73
+ id: `cursor-api-${fingerprint}`,
74
+ taskId: conversationId ?? 'cursor-imported',
75
+ sessionId: conversationId,
76
+ provider: 'cursor',
77
+ model,
78
+ inputTokens,
79
+ outputTokens,
80
+ cachedInputTokens: cacheReadTokens,
81
+ cacheWriteTokens,
82
+ observedCostUsd,
83
+ source: CURSOR_ADMIN_API_SOURCE,
84
+ createdAt,
85
+ },
86
+ };
87
+ }
88
+ export function parseCursorUsageApiResponse(body) {
89
+ if (!isRecord(body)) {
90
+ return {};
91
+ }
92
+ return {
93
+ totalUsageEventsCount: readNumber(body.totalUsageEventsCount),
94
+ usageEvents: Array.isArray(body.usageEvents) ? body.usageEvents : [],
95
+ pagination: isRecord(body.pagination) ? {
96
+ hasNextPage: Boolean(body.pagination.hasNextPage),
97
+ currentPage: readNumber(body.pagination.currentPage, 1),
98
+ numPages: readNumber(body.pagination.numPages, 1),
99
+ pageSize: readNumber(body.pagination.pageSize, 0),
100
+ } : undefined,
101
+ };
102
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import fs from 'fs-extra';
5
+ vi.mock('../storage/db.js', () => ({
6
+ isSQLiteAvailable: () => false,
7
+ openDatabase: async () => null,
8
+ DB_PATH: '/tmp/rigour-test.db',
9
+ RIGOUR_DIR: '/tmp/.rigour',
10
+ }));
11
+ import { fetchCursorUsageFromAdminApi, syncCursorUsageFromAdminApi, getTaskCostStats, } from './context-telemetry-service.js';
12
+ import { getModelUsages } from '../storage/context-telemetry.js';
13
+ import { centsToUsd, normalizeCursorUsageEvent } from './cursor-usage-normalizer.js';
14
+ import { CURSOR_FILTERED_USAGE_URL } from './cursor-usage-client.js';
15
+ function buildUsageEvent(overrides = {}) {
16
+ return {
17
+ timestamp: '1750979225854',
18
+ userEmail: 'dev@example.com',
19
+ conversationId: 'conv-1',
20
+ model: 'composer-2.5-standard',
21
+ isTokenBasedCall: true,
22
+ tokenUsage: {
23
+ inputTokens: 100,
24
+ outputTokens: 50,
25
+ cacheWriteTokens: 10,
26
+ cacheReadTokens: 20,
27
+ totalCents: 12.5,
28
+ },
29
+ chargedCents: 15.25,
30
+ ...overrides,
31
+ };
32
+ }
33
+ describe('cursor admin usage sync', () => {
34
+ const testCwd = path.join(os.tmpdir(), `rigour-cursor-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`);
35
+ const apiKey = 'test-cursor-admin-key';
36
+ beforeEach(async () => {
37
+ await fs.ensureDir(path.join(testCwd, '.rigour'));
38
+ });
39
+ afterEach(async () => {
40
+ await fs.remove(testCwd);
41
+ vi.restoreAllMocks();
42
+ });
43
+ it('imports a single-page response with basic auth header', async () => {
44
+ const fetchImpl = vi.fn(async (_url, init) => ({
45
+ ok: true,
46
+ status: 200,
47
+ json: async () => ({
48
+ usageEvents: [buildUsageEvent()],
49
+ pagination: { hasNextPage: false, currentPage: 1 },
50
+ }),
51
+ }));
52
+ const imported = await fetchCursorUsageFromAdminApi({
53
+ cwd: testCwd,
54
+ apiKey,
55
+ fetchImpl,
56
+ });
57
+ expect(imported).toBe(1);
58
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
59
+ const [, init] = fetchImpl.mock.calls[0];
60
+ expect(init.method).toBe('POST');
61
+ expect(init.headers.Authorization).toBe(`Basic ${Buffer.from(`${apiKey}:`, 'utf8').toString('base64')}`);
62
+ expect(init.headers['Content-Type']).toBe('application/json');
63
+ const body = JSON.parse(init.body);
64
+ expect(body.pageSize).toBeGreaterThan(0);
65
+ expect(body.startDate).toBeLessThan(body.endDate);
66
+ });
67
+ it('paginates until hasNextPage is false', async () => {
68
+ let page = 0;
69
+ const fetchImpl = vi.fn(async () => {
70
+ page += 1;
71
+ return {
72
+ ok: true,
73
+ status: 200,
74
+ json: async () => ({
75
+ usageEvents: [buildUsageEvent({ conversationId: `conv-${page}` })],
76
+ pagination: { hasNextPage: page < 2, currentPage: page },
77
+ }),
78
+ };
79
+ });
80
+ const result = await syncCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl });
81
+ expect(result.importedCount).toBe(2);
82
+ expect(result.totalEvents).toBe(2);
83
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
84
+ });
85
+ it('converts cents to USD and stores token fields', async () => {
86
+ const normalized = normalizeCursorUsageEvent(buildUsageEvent());
87
+ expect(normalized?.usage.observedCostUsd).toBe(centsToUsd(15.25));
88
+ expect(normalized?.usage.inputTokens).toBe(100);
89
+ expect(normalized?.usage.outputTokens).toBe(50);
90
+ expect(normalized?.usage.cachedInputTokens).toBe(20);
91
+ expect(normalized?.usage.cacheWriteTokens).toBe(10);
92
+ expect(normalized?.usage.source).toBe('cursor-admin-api');
93
+ });
94
+ it('deduplicates repeated syncs', async () => {
95
+ const fetchImpl = vi.fn(async () => ({
96
+ ok: true,
97
+ status: 200,
98
+ json: async () => ({
99
+ usageEvents: [buildUsageEvent()],
100
+ pagination: { hasNextPage: false },
101
+ }),
102
+ }));
103
+ const first = await fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl });
104
+ const second = await fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl });
105
+ expect(first).toBe(1);
106
+ expect(second).toBe(0);
107
+ const usages = await getModelUsages(undefined, testCwd);
108
+ expect(usages.filter(u => u.source === 'cursor-admin-api')).toHaveLength(1);
109
+ });
110
+ it('surfaces actionable non-2xx errors without exposing the API key', async () => {
111
+ const fetchImpl = vi.fn(async () => ({
112
+ ok: false,
113
+ status: 401,
114
+ text: async () => `Unauthorized for key ${apiKey}`,
115
+ }));
116
+ await expect(fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl })).rejects.toThrow(/401/);
117
+ await expect(fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl })).rejects.not.toThrow(apiKey);
118
+ });
119
+ it('skips malformed events and handles partial fields', async () => {
120
+ const fetchImpl = vi.fn(async () => ({
121
+ ok: true,
122
+ status: 200,
123
+ json: async () => ({
124
+ usageEvents: [
125
+ null,
126
+ { model: 'composer-2.5-fast', chargedCents: 5 },
127
+ buildUsageEvent(),
128
+ ],
129
+ pagination: { hasNextPage: false },
130
+ }),
131
+ }));
132
+ const imported = await fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl });
133
+ expect(imported).toBe(2);
134
+ });
135
+ it('marks observed cost stats from imported admin API events', async () => {
136
+ const fetchImpl = vi.fn(async () => ({
137
+ ok: true,
138
+ status: 200,
139
+ json: async () => ({
140
+ usageEvents: [buildUsageEvent({ conversationId: 'cost-task' })],
141
+ pagination: { hasNextPage: false },
142
+ }),
143
+ }));
144
+ await fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl });
145
+ const cost = await getTaskCostStats('cost-task', testCwd);
146
+ expect(cost.actual.isEstimated).toBe(false);
147
+ expect(cost.actual.costUsd).toBeGreaterThan(0);
148
+ expect(cost.actual.pricingBasis).toBe('observed-cursor-charged-cents');
149
+ });
150
+ it('targets the filtered usage events endpoint', () => {
151
+ expect(CURSOR_FILTERED_USAGE_URL).toBe('https://api.cursor.com/teams/filtered-usage-events');
152
+ });
153
+ it('errors when pagination exceeds the defensive page limit', async () => {
154
+ let page = 0;
155
+ const fetchImpl = vi.fn(async () => {
156
+ page += 1;
157
+ return {
158
+ ok: true,
159
+ status: 200,
160
+ json: async () => ({
161
+ usageEvents: [buildUsageEvent({ conversationId: `conv-page-${page}` })],
162
+ pagination: { hasNextPage: true, currentPage: page },
163
+ }),
164
+ };
165
+ });
166
+ await expect(fetchCursorUsageFromAdminApi({
167
+ cwd: testCwd,
168
+ apiKey,
169
+ fetchImpl,
170
+ maxPages: 2,
171
+ })).rejects.toThrow(/truncated after 2 pages/);
172
+ });
173
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Model-aware input pricing for context cost telemetry.
3
+ * Observed Cursor costs always take precedence; these rates estimate when absent.
4
+ */
5
+ import type { ModelUsage } from '../storage/context-telemetry.js';
6
+ export interface ModelPricingTier {
7
+ label: string;
8
+ inputPerMillionUsd: number;
9
+ outputPerMillionUsd: number;
10
+ }
11
+ /** Per-million-token USD rates. Input rates drive avoided-context estimates. */
12
+ export declare const MODEL_PRICING: Record<string, ModelPricingTier>;
13
+ /** Conservative fallback when model cannot be resolved (USD per 1M input tokens). */
14
+ export declare const FALLBACK_INPUT_PRICE_PER_MILLION_USD = 3;
15
+ export declare const FALLBACK_OUTPUT_PRICE_PER_MILLION_USD = 15;
16
+ /**
17
+ * Normalize model identifiers from Cursor, CLI flags, or imports.
18
+ */
19
+ export declare function normalizeModelKey(model: string | undefined): string;
20
+ export declare function getModelPricing(model: string | undefined): ModelPricingTier | undefined;
21
+ export declare function getInputPricePerMillionUsd(model: string | undefined): number;
22
+ export declare function getOutputPricePerMillionUsd(model: string | undefined): number;
23
+ export interface WeightedInputPricing {
24
+ inputPricePerMillionUsd: number;
25
+ pricingBasis: string;
26
+ isEstimated: boolean;
27
+ }
28
+ /**
29
+ * Input-token-weighted effective input price across observed model usages.
30
+ */
31
+ export declare function computeWeightedInputPricePerMillion(usages: ModelUsage[]): WeightedInputPricing;
32
+ export declare function estimateTokenCostUsd(inputTokens: number, outputTokens: number, model: string | undefined): number;
33
+ export declare function estimateAvoidedContextCostUsd(avoidedInputTokens: number, usages: ModelUsage[]): {
34
+ costUsd: number;
35
+ pricing: WeightedInputPricing;
36
+ };
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Model-aware input pricing for context cost telemetry.
3
+ * Observed Cursor costs always take precedence; these rates estimate when absent.
4
+ */
5
+ /** Per-million-token USD rates. Input rates drive avoided-context estimates. */
6
+ export const MODEL_PRICING = {
7
+ 'composer-2.5-standard': {
8
+ label: 'Composer 2.5 Standard',
9
+ inputPerMillionUsd: 0.5,
10
+ outputPerMillionUsd: 2.5,
11
+ },
12
+ 'composer-2.5-fast': {
13
+ label: 'Composer 2.5 Fast',
14
+ inputPerMillionUsd: 3.0,
15
+ outputPerMillionUsd: 15.0,
16
+ },
17
+ 'claude-3-5-sonnet': {
18
+ label: 'Claude 3.5 Sonnet',
19
+ inputPerMillionUsd: 3.0,
20
+ outputPerMillionUsd: 15.0,
21
+ },
22
+ 'gpt-4o': {
23
+ label: 'GPT-4o',
24
+ inputPerMillionUsd: 2.5,
25
+ outputPerMillionUsd: 10.0,
26
+ },
27
+ 'gpt-4o-mini': {
28
+ label: 'GPT-4o Mini',
29
+ inputPerMillionUsd: 0.15,
30
+ outputPerMillionUsd: 0.6,
31
+ },
32
+ 'o1-preview': {
33
+ label: 'o1 Preview',
34
+ inputPerMillionUsd: 15.0,
35
+ outputPerMillionUsd: 60.0,
36
+ },
37
+ };
38
+ /** Conservative fallback when model cannot be resolved (USD per 1M input tokens). */
39
+ export const FALLBACK_INPUT_PRICE_PER_MILLION_USD = 3.0;
40
+ export const FALLBACK_OUTPUT_PRICE_PER_MILLION_USD = 15.0;
41
+ const MODEL_ALIASES = {
42
+ 'composer-2.5': 'composer-2.5-standard',
43
+ 'composer-25-standard': 'composer-2.5-standard',
44
+ 'composer-25-fast': 'composer-2.5-fast',
45
+ 'composer-2-5-standard': 'composer-2.5-standard',
46
+ 'composer-2-5-fast': 'composer-2.5-fast',
47
+ 'composer25standard': 'composer-2.5-standard',
48
+ 'composer25fast': 'composer-2.5-fast',
49
+ };
50
+ /**
51
+ * Normalize model identifiers from Cursor, CLI flags, or imports.
52
+ */
53
+ export function normalizeModelKey(model) {
54
+ if (!model)
55
+ return 'unknown';
56
+ const lowered = model.trim().toLowerCase();
57
+ const compact = lowered
58
+ .replace(/[_\s]+/g, '-')
59
+ .replace(/[^a-z0-9.-]/g, '')
60
+ .replace(/-+/g, '-');
61
+ if (MODEL_ALIASES[compact]) {
62
+ return MODEL_ALIASES[compact];
63
+ }
64
+ if (compact.includes('composer') && compact.includes('fast')) {
65
+ return 'composer-2.5-fast';
66
+ }
67
+ if (compact.includes('composer')) {
68
+ return 'composer-2.5-standard';
69
+ }
70
+ if (compact.includes('claude') && compact.includes('sonnet')) {
71
+ return 'claude-3-5-sonnet';
72
+ }
73
+ if (compact.includes('gpt-4o-mini') || compact.includes('gpt4omini')) {
74
+ return 'gpt-4o-mini';
75
+ }
76
+ if (compact.includes('gpt-4o') || compact.includes('gpt4o')) {
77
+ return 'gpt-4o';
78
+ }
79
+ if (compact.includes('o1-preview') || compact.includes('o1preview')) {
80
+ return 'o1-preview';
81
+ }
82
+ return compact;
83
+ }
84
+ export function getModelPricing(model) {
85
+ const key = normalizeModelKey(model);
86
+ return MODEL_PRICING[key];
87
+ }
88
+ export function getInputPricePerMillionUsd(model) {
89
+ return getModelPricing(model)?.inputPerMillionUsd ?? FALLBACK_INPUT_PRICE_PER_MILLION_USD;
90
+ }
91
+ export function getOutputPricePerMillionUsd(model) {
92
+ return getModelPricing(model)?.outputPerMillionUsd ?? FALLBACK_OUTPUT_PRICE_PER_MILLION_USD;
93
+ }
94
+ /**
95
+ * Input-token-weighted effective input price across observed model usages.
96
+ */
97
+ export function computeWeightedInputPricePerMillion(usages) {
98
+ const withInput = usages.filter(u => (u.inputTokens || 0) > 0 && u.model);
99
+ if (withInput.length === 0) {
100
+ return {
101
+ inputPricePerMillionUsd: FALLBACK_INPUT_PRICE_PER_MILLION_USD,
102
+ pricingBasis: 'conservative-fallback-unknown-model',
103
+ isEstimated: true,
104
+ };
105
+ }
106
+ let weightedSum = 0;
107
+ let totalInput = 0;
108
+ const modelsUsed = new Set();
109
+ for (const usage of withInput) {
110
+ const tokens = usage.inputTokens || 0;
111
+ const price = getInputPricePerMillionUsd(usage.model);
112
+ weightedSum += price * tokens;
113
+ totalInput += tokens;
114
+ modelsUsed.add(normalizeModelKey(usage.model));
115
+ }
116
+ const effective = totalInput > 0 ? weightedSum / totalInput : FALLBACK_INPUT_PRICE_PER_MILLION_USD;
117
+ const basis = modelsUsed.size === 1
118
+ ? `model:${Array.from(modelsUsed)[0]}`
119
+ : `mixed-model-weighted:${Array.from(modelsUsed).join(',')}`;
120
+ return {
121
+ inputPricePerMillionUsd: effective,
122
+ pricingBasis: basis,
123
+ isEstimated: true,
124
+ };
125
+ }
126
+ export function estimateTokenCostUsd(inputTokens, outputTokens, model) {
127
+ const inputPrice = getInputPricePerMillionUsd(model);
128
+ const outputPrice = getOutputPricePerMillionUsd(model);
129
+ const inputCost = (inputTokens / 1_000_000) * inputPrice;
130
+ const outputCost = (outputTokens / 1_000_000) * outputPrice;
131
+ return parseFloat((inputCost + outputCost).toFixed(4));
132
+ }
133
+ export function estimateAvoidedContextCostUsd(avoidedInputTokens, usages) {
134
+ const pricing = computeWeightedInputPricePerMillion(usages);
135
+ const costUsd = parseFloat(((avoidedInputTokens / 1_000_000) * pricing.inputPricePerMillionUsd).toFixed(4));
136
+ return { costUsd, pricing };
137
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { normalizeModelKey, getInputPricePerMillionUsd, computeWeightedInputPricePerMillion, estimateAvoidedContextCostUsd, estimateTokenCostUsd, FALLBACK_INPUT_PRICE_PER_MILLION_USD, } from './model-pricing.js';
3
+ describe('model-pricing', () => {
4
+ it('resolves Composer 2.5 Standard input pricing', () => {
5
+ expect(getInputPricePerMillionUsd('Composer 2.5 Standard')).toBe(0.5);
6
+ expect(normalizeModelKey('composer-2-5-standard')).toBe('composer-2.5-standard');
7
+ });
8
+ it('resolves Composer 2.5 Fast input pricing', () => {
9
+ expect(getInputPricePerMillionUsd('composer-2.5-fast')).toBe(3.0);
10
+ expect(normalizeModelKey('Composer 2.5 Fast')).toBe('composer-2.5-fast');
11
+ });
12
+ it('normalizes aliases with spaces and hyphens', () => {
13
+ expect(normalizeModelKey('Composer_2.5 FAST')).toBe('composer-2.5-fast');
14
+ expect(normalizeModelKey('composer 25 standard')).toBe('composer-2.5-standard');
15
+ });
16
+ it('uses conservative fallback for unknown models', () => {
17
+ expect(getInputPricePerMillionUsd('totally-unknown-model')).toBe(FALLBACK_INPUT_PRICE_PER_MILLION_USD);
18
+ });
19
+ it('computes mixed-model input-token-weighted pricing', () => {
20
+ const usages = [
21
+ { source: 'cursor-admin-api', model: 'composer-2.5-standard', inputTokens: 1000, outputTokens: 0 },
22
+ { source: 'cursor-admin-api', model: 'composer-2.5-fast', inputTokens: 3000, outputTokens: 0 },
23
+ ];
24
+ const weighted = computeWeightedInputPricePerMillion(usages);
25
+ expect(weighted.inputPricePerMillionUsd).toBeCloseTo(2.375, 3);
26
+ expect(weighted.pricingBasis).toContain('mixed-model-weighted');
27
+ });
28
+ it('prefers observed cost when estimating actual spend is unnecessary', () => {
29
+ const observed = 4.25;
30
+ expect(observed).toBeGreaterThan(0);
31
+ });
32
+ it('estimates actual token cost when observed cost is absent', () => {
33
+ const estimated = estimateTokenCostUsd(1_000_000, 0, 'composer-2.5-standard');
34
+ expect(estimated).toBe(0.5);
35
+ });
36
+ it('estimates avoided context using input pricing only', () => {
37
+ const usages = [
38
+ { source: 'cursor-admin-api', model: 'composer-2.5-standard', inputTokens: 100, outputTokens: 0 },
39
+ ];
40
+ const { costUsd, pricing } = estimateAvoidedContextCostUsd(2_000_000, usages);
41
+ expect(pricing.inputPricePerMillionUsd).toBe(0.5);
42
+ expect(costUsd).toBe(1.0);
43
+ });
44
+ });
@@ -106,10 +106,23 @@ export declare function updateProviderKey(provider: string, apiKey: string): voi
106
106
  */
107
107
  export declare function removeProviderKey(provider: string): void;
108
108
  /**
109
- * Get Cursor Admin API key from settings
109
+ * Get Cursor Admin API key — prefer env over file-stored secret.
110
+ * Env: RIGOUR_CURSOR_API_KEY or CURSOR_ADMIN_API_KEY
110
111
  */
111
112
  export declare function getCursorApiKey(): string | undefined;
112
113
  /**
113
- * Update Cursor Admin API key in settings
114
+ * Whether a Cursor vendor adapter key is available (env or file).
115
+ */
116
+ export declare function isCursorApiKeyConfigured(): boolean;
117
+ /**
118
+ * Last-4 hint for UI status — never return the full key.
119
+ */
120
+ export declare function getCursorApiKeyHint(): string | undefined;
121
+ /**
122
+ * Persist Cursor Admin API key to settings (optional; prefer env in production).
114
123
  */
115
124
  export declare function updateCursorApiKey(apiKey: string): void;
125
+ /**
126
+ * Remove file-stored Cursor Admin API key (env vars are untouched).
127
+ */
128
+ export declare function removeCursorApiKey(): void;
package/dist/settings.js CHANGED
@@ -45,10 +45,22 @@ export function saveSettings(settings) {
45
45
  const settingsPath = getSettingsPath();
46
46
  const settingsDir = path.dirname(settingsPath);
47
47
  try {
48
- // Ensure directory exists
48
+ // Ensure directory exists with owner-only access
49
49
  fs.ensureDirSync(settingsDir);
50
+ try {
51
+ fs.chmodSync(settingsDir, 0o700);
52
+ }
53
+ catch {
54
+ // ignore chmod failures on platforms that do not support it
55
+ }
50
56
  // Write with pretty formatting (2-space indent)
51
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
57
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { encoding: 'utf-8', mode: 0o600 });
58
+ try {
59
+ fs.chmodSync(settingsPath, 0o600);
60
+ }
61
+ catch {
62
+ // ignore chmod failures on platforms that do not support it
63
+ }
52
64
  Logger.debug(`Settings saved to ${settingsPath}`);
53
65
  }
54
66
  catch (error) {
@@ -185,20 +197,58 @@ export function removeProviderKey(provider) {
185
197
  }
186
198
  }
187
199
  /**
188
- * Get Cursor Admin API key from settings
200
+ * Get Cursor Admin API key — prefer env over file-stored secret.
201
+ * Env: RIGOUR_CURSOR_API_KEY or CURSOR_ADMIN_API_KEY
189
202
  */
190
203
  export function getCursorApiKey() {
204
+ const fromEnv = process.env.RIGOUR_CURSOR_API_KEY?.trim() ||
205
+ process.env.CURSOR_ADMIN_API_KEY?.trim();
206
+ if (fromEnv)
207
+ return fromEnv;
191
208
  const settings = loadSettings();
192
- return settings.cursor?.apiKey;
209
+ const fromFile = settings.cursor?.apiKey?.trim();
210
+ return fromFile || undefined;
211
+ }
212
+ /**
213
+ * Whether a Cursor vendor adapter key is available (env or file).
214
+ */
215
+ export function isCursorApiKeyConfigured() {
216
+ return Boolean(getCursorApiKey());
193
217
  }
194
218
  /**
195
- * Update Cursor Admin API key in settings
219
+ * Last-4 hint for UI status — never return the full key.
220
+ */
221
+ export function getCursorApiKeyHint() {
222
+ const key = getCursorApiKey();
223
+ if (!key || key.length < 4)
224
+ return undefined;
225
+ return `••••${key.slice(-4)}`;
226
+ }
227
+ /**
228
+ * Persist Cursor Admin API key to settings (optional; prefer env in production).
196
229
  */
197
230
  export function updateCursorApiKey(apiKey) {
231
+ const trimmed = apiKey.trim();
232
+ if (!trimmed || trimmed.length > 512) {
233
+ throw new Error('Invalid Cursor Admin API key');
234
+ }
198
235
  const settings = loadSettings();
199
236
  if (!settings.cursor) {
200
237
  settings.cursor = {};
201
238
  }
202
- settings.cursor.apiKey = apiKey;
239
+ settings.cursor.apiKey = trimmed;
203
240
  saveSettings(settings);
204
241
  }
242
+ /**
243
+ * Remove file-stored Cursor Admin API key (env vars are untouched).
244
+ */
245
+ export function removeCursorApiKey() {
246
+ const settings = loadSettings();
247
+ if (settings.cursor?.apiKey) {
248
+ delete settings.cursor.apiKey;
249
+ if (Object.keys(settings.cursor).length === 0) {
250
+ delete settings.cursor;
251
+ }
252
+ saveSettings(settings);
253
+ }
254
+ }
@@ -23,6 +23,7 @@ export interface ModelUsage {
23
23
  inputTokens: number;
24
24
  outputTokens: number;
25
25
  cachedInputTokens?: number;
26
+ cacheWriteTokens?: number;
26
27
  observedCostUsd?: number;
27
28
  source: string;
28
29
  createdAt?: number;
@@ -61,6 +62,14 @@ export declare function recordModelUsage(usage: ModelUsage, cwd?: string): Promi
61
62
  * Insert or update cache entry.
62
63
  */
63
64
  export declare function setContextCacheRecord(record: ContextCacheRecord, cwd?: string): Promise<void>;
65
+ /**
66
+ * List cache records by type (and optional commit). Does not increment hit counts.
67
+ */
68
+ export declare function listContextCacheRecords(opts: {
69
+ cacheType?: ContextCacheRecord['cacheType'];
70
+ commitSha?: string;
71
+ limit?: number;
72
+ }, cwd?: string): Promise<ContextCacheRecord[]>;
64
73
  /**
65
74
  * Fetch cached context record by cache key. Increments hit count.
66
75
  */