@aixle/insights 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/auth/credentials.d.ts +23 -0
- package/dist/auth/credentials.js +174 -0
- package/dist/auth/exchange.d.ts +25 -0
- package/dist/auth/exchange.js +87 -0
- package/dist/auth/flow.d.ts +24 -0
- package/dist/auth/flow.js +66 -0
- package/dist/auth/keycloak.d.ts +35 -0
- package/dist/auth/keycloak.js +170 -0
- package/dist/cli.d.ts +51 -0
- package/dist/cli.js +426 -0
- package/dist/client.d.ts +28 -0
- package/dist/client.js +102 -0
- package/dist/collect-cursor-payloads.d.ts +57 -0
- package/dist/collect-cursor-payloads.js +134 -0
- package/dist/credentials.d.ts +2 -0
- package/dist/credentials.js +1 -0
- package/dist/cursor-checkpoints.d.ts +12 -0
- package/dist/cursor-checkpoints.js +28 -0
- package/dist/cursor-config.d.ts +5 -0
- package/dist/cursor-config.js +34 -0
- package/dist/cursor-payload-contract.d.ts +17 -0
- package/dist/cursor-payload-contract.js +258 -0
- package/dist/cursor-settings.d.ts +6 -0
- package/dist/cursor-settings.js +38 -0
- package/dist/cursor-store-audit.d.ts +48 -0
- package/dist/cursor-store-audit.js +155 -0
- package/dist/daily-stats-versions.d.ts +31 -0
- package/dist/daily-stats-versions.js +170 -0
- package/dist/health.d.ts +31 -0
- package/dist/health.js +195 -0
- package/dist/hooks/cursor-hooks-mapper.d.ts +22 -0
- package/dist/hooks/cursor-hooks-mapper.js +84 -0
- package/dist/hooks/cursor-hooks-reader.d.ts +30 -0
- package/dist/hooks/cursor-hooks-reader.js +117 -0
- package/dist/hooks/hook-forwarder.mjs +110 -0
- package/dist/hooks/hooks-config.d.ts +92 -0
- package/dist/hooks/hooks-config.js +235 -0
- package/dist/install/claude.d.ts +37 -0
- package/dist/install/claude.js +144 -0
- package/dist/install/index.d.ts +8 -0
- package/dist/install/index.js +11 -0
- package/dist/lib/args.d.ts +26 -0
- package/dist/lib/args.js +17 -0
- package/dist/lib/client.d.ts +33 -0
- package/dist/lib/client.js +52 -0
- package/dist/lib/config.d.ts +26 -0
- package/dist/lib/config.js +39 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.js +4 -0
- package/dist/lib/project-resolver.d.ts +48 -0
- package/dist/lib/project-resolver.js +203 -0
- package/dist/lock.d.ts +9 -0
- package/dist/lock.js +84 -0
- package/dist/log.d.ts +14 -0
- package/dist/log.js +81 -0
- package/dist/pricing.d.ts +40 -0
- package/dist/pricing.js +149 -0
- package/dist/readers/claude.d.ts +83 -0
- package/dist/readers/claude.js +317 -0
- package/dist/readers/cursor.d.ts +134 -0
- package/dist/readers/cursor.js +900 -0
- package/dist/risk-scanner.d.ts +8 -0
- package/dist/risk-scanner.js +59 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +234 -0
- package/dist/state.d.ts +69 -0
- package/dist/state.js +155 -0
- package/dist/sync.d.ts +74 -0
- package/dist/sync.js +679 -0
- package/package.json +66 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install / uninstall / verify Cursor hooks config for the aixle-insights hook-forwarder.
|
|
3
|
+
*/
|
|
4
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
export const USER_HOOKS_JSON = join(homedir(), ".cursor", "hooks.json");
|
|
8
|
+
export const HOOKS_BACKUP_SUFFIX = ".db90-backup";
|
|
9
|
+
export const FORWARDER_FILENAME = "hook-forwarder.mjs";
|
|
10
|
+
export const REQUIRED_HOOK_FIELDS = ["conversation_id", "model", "workspace_roots"];
|
|
11
|
+
export const P0_HOOK_EVENTS = ["sessionEnd", "postToolUse"];
|
|
12
|
+
export function redactHomePath(p) {
|
|
13
|
+
return p.replaceAll(homedir(), "~");
|
|
14
|
+
}
|
|
15
|
+
export function parseHooksJson(raw) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(raw);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function hooksConfigUsesForwarder(config, forwarderPath) {
|
|
24
|
+
const normalized = resolve(forwarderPath);
|
|
25
|
+
const hooks = config.hooks ?? {};
|
|
26
|
+
for (const entries of Object.values(hooks)) {
|
|
27
|
+
if (!Array.isArray(entries))
|
|
28
|
+
continue;
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
// Current format: forwarder path lives inside the single command string.
|
|
31
|
+
if (typeof entry?.command === "string" && entry.command.includes(FORWARDER_FILENAME)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
// Legacy format (command + args): tolerated for detection/uninstall.
|
|
35
|
+
if (Array.isArray(entry?.args)) {
|
|
36
|
+
for (const arg of entry.args) {
|
|
37
|
+
if (resolve(arg) === normalized || arg.includes(FORWARDER_FILENAME))
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Quote a path for safe inclusion in a POSIX shell command string.
|
|
47
|
+
* Single-quoting suppresses all expansions ($var, `cmd`, $(cmd), glob, history).
|
|
48
|
+
* The only character that cannot appear inside single quotes is a literal single
|
|
49
|
+
* quote, which we escape by ending the quote, adding an escaped quote, then
|
|
50
|
+
* reopening: foo'bar → 'foo'\''bar'
|
|
51
|
+
*/
|
|
52
|
+
function shellQuote(p) {
|
|
53
|
+
return `'${p.replaceAll("'", "'\\''")}'`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build the single-string `command` Cursor runs for each hook. Cursor's schema
|
|
57
|
+
* has no `args`/`env`, so the interpreter, script path, and appDir flag are all
|
|
58
|
+
* encoded inline. The forwarder reads `--app-dir` to resolve the queue path,
|
|
59
|
+
* since it runs as a Cursor subprocess outside the MCP process.
|
|
60
|
+
*/
|
|
61
|
+
export function buildForwarderCommand(forwarderPath, appDir) {
|
|
62
|
+
return `node ${shellQuote(resolve(forwarderPath))} --app-dir ${shellQuote(appDir)}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build the hooks.json config that routes P0 events to the forwarder.
|
|
66
|
+
*/
|
|
67
|
+
export function buildUserHooksConfig(forwarderPath, appDir) {
|
|
68
|
+
const entry = {
|
|
69
|
+
command: buildForwarderCommand(forwarderPath, appDir),
|
|
70
|
+
};
|
|
71
|
+
const hooks = {};
|
|
72
|
+
for (const event of P0_HOOK_EVENTS) {
|
|
73
|
+
hooks[event] = [entry];
|
|
74
|
+
}
|
|
75
|
+
return { version: 1, hooks };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Install the db90 hook-forwarder:
|
|
79
|
+
* 1. Copy hook-forwarder.mjs from srcForwarderPath → {appDir}/hook-forwarder.mjs
|
|
80
|
+
* 2. Backup existing ~/.cursor/hooks.json if present
|
|
81
|
+
* 3. Write new hooks.json pointing at the installed forwarder
|
|
82
|
+
*/
|
|
83
|
+
export function installHooksConfig(srcForwarderPath, appDir) {
|
|
84
|
+
mkdirSync(appDir, { recursive: true });
|
|
85
|
+
mkdirSync(join(homedir(), ".cursor"), { recursive: true });
|
|
86
|
+
const installedForwarder = join(appDir, FORWARDER_FILENAME);
|
|
87
|
+
copyFileSync(srcForwarderPath, installedForwarder);
|
|
88
|
+
let backupPath = null;
|
|
89
|
+
if (existsSync(USER_HOOKS_JSON)) {
|
|
90
|
+
const candidate = USER_HOOKS_JSON + HOOKS_BACKUP_SUFFIX;
|
|
91
|
+
if (existsSync(candidate)) {
|
|
92
|
+
// A prior install already captured the user's original — never clobber it.
|
|
93
|
+
backupPath = candidate;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// Only back up if the current config is the user's, not our own install.
|
|
97
|
+
const current = parseHooksJson(readFileSync(USER_HOOKS_JSON, "utf-8"));
|
|
98
|
+
const alreadyOurs = current !== null && hooksConfigUsesForwarder(current, installedForwarder);
|
|
99
|
+
if (!alreadyOurs) {
|
|
100
|
+
backupPath = candidate;
|
|
101
|
+
copyFileSync(USER_HOOKS_JSON, candidate);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const config = buildUserHooksConfig(installedForwarder, appDir);
|
|
106
|
+
const tmp = USER_HOOKS_JSON + ".tmp";
|
|
107
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
108
|
+
renameSync(tmp, USER_HOOKS_JSON);
|
|
109
|
+
return { forwarderInstalled: installedForwarder, backupPath };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Restore the backed-up hooks.json (or remove the current one if no backup existed).
|
|
113
|
+
* Warns (does not throw) if a queue file with unprocessed events exists.
|
|
114
|
+
*/
|
|
115
|
+
export function uninstallHooksConfig(appDir, queuePath) {
|
|
116
|
+
const backupPath = USER_HOOKS_JSON + HOOKS_BACKUP_SUFFIX;
|
|
117
|
+
let restored = false;
|
|
118
|
+
let usedBackup = null;
|
|
119
|
+
if (existsSync(backupPath)) {
|
|
120
|
+
copyFileSync(backupPath, USER_HOOKS_JSON);
|
|
121
|
+
try {
|
|
122
|
+
unlinkSync(backupPath);
|
|
123
|
+
}
|
|
124
|
+
catch { /* non-fatal */ }
|
|
125
|
+
restored = true;
|
|
126
|
+
usedBackup = backupPath;
|
|
127
|
+
}
|
|
128
|
+
else if (existsSync(USER_HOOKS_JSON)) {
|
|
129
|
+
const current = parseHooksJson(readFileSync(USER_HOOKS_JSON, "utf-8"));
|
|
130
|
+
const installedForwarder = join(appDir, FORWARDER_FILENAME);
|
|
131
|
+
if (current && hooksConfigUsesForwarder(current, installedForwarder)) {
|
|
132
|
+
try {
|
|
133
|
+
unlinkSync(USER_HOOKS_JSON);
|
|
134
|
+
}
|
|
135
|
+
catch { /* non-fatal */ }
|
|
136
|
+
restored = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const resolvedQueue = queuePath ?? join(appDir, "hooks-queue.ndjson");
|
|
140
|
+
let queueWarning = null;
|
|
141
|
+
if (existsSync(resolvedQueue)) {
|
|
142
|
+
const lines = readHooksQueue(resolvedQueue);
|
|
143
|
+
if (lines.length > 0) {
|
|
144
|
+
queueWarning = `hooks-queue.ndjson has ${lines.length} unprocessed event(s) — run aixle-insights run --once before uninstalling to flush them`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { restored, backupPath: usedBackup, queueWarning };
|
|
148
|
+
}
|
|
149
|
+
export function readHooksQueue(queuePath) {
|
|
150
|
+
if (!existsSync(queuePath))
|
|
151
|
+
return [];
|
|
152
|
+
const raw = readFileSync(queuePath, "utf-8");
|
|
153
|
+
const events = [];
|
|
154
|
+
for (const line of raw.split("\n")) {
|
|
155
|
+
const trimmed = line.trim();
|
|
156
|
+
if (!trimmed)
|
|
157
|
+
continue;
|
|
158
|
+
try {
|
|
159
|
+
events.push(JSON.parse(trimmed));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
events.push({ hook_event_name: "log_parse_error" });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return events;
|
|
166
|
+
}
|
|
167
|
+
function isNonEmptyString(v) {
|
|
168
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
169
|
+
}
|
|
170
|
+
function workspaceRootsPopulated(v) {
|
|
171
|
+
if (!Array.isArray(v) || v.length === 0)
|
|
172
|
+
return false;
|
|
173
|
+
return v.some((r) => isNonEmptyString(r));
|
|
174
|
+
}
|
|
175
|
+
export function analyzeHookEvent(event) {
|
|
176
|
+
const field_checks = REQUIRED_HOOK_FIELDS.map((field) => {
|
|
177
|
+
const value = event[field];
|
|
178
|
+
const present = value !== undefined;
|
|
179
|
+
let populated = false;
|
|
180
|
+
let note;
|
|
181
|
+
if (field === "workspace_roots") {
|
|
182
|
+
populated = workspaceRootsPopulated(value);
|
|
183
|
+
if (present && !populated)
|
|
184
|
+
note = "empty or non-string array";
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
populated = isNonEmptyString(value);
|
|
188
|
+
if (present && !populated)
|
|
189
|
+
note = "empty string";
|
|
190
|
+
}
|
|
191
|
+
return { field, present, populated, note };
|
|
192
|
+
});
|
|
193
|
+
return {
|
|
194
|
+
captured_at: typeof event.captured_at === "string" ? event.captured_at : null,
|
|
195
|
+
hook_event_name: typeof event.hook_event_name === "string" ? event.hook_event_name : null,
|
|
196
|
+
field_checks,
|
|
197
|
+
passes_required_fields: field_checks.every((c) => c.populated),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export function verifyHooksConfig(appDir) {
|
|
201
|
+
const installedForwarder = join(appDir, FORWARDER_FILENAME);
|
|
202
|
+
const queuePath = join(appDir, "hooks-queue.ndjson");
|
|
203
|
+
let hooks_json_installed = false;
|
|
204
|
+
if (existsSync(USER_HOOKS_JSON)) {
|
|
205
|
+
const config = parseHooksJson(readFileSync(USER_HOOKS_JSON, "utf-8"));
|
|
206
|
+
hooks_json_installed = config !== null && hooksConfigUsesForwarder(config, installedForwarder);
|
|
207
|
+
}
|
|
208
|
+
const events = readHooksQueue(queuePath);
|
|
209
|
+
const analyzed = events.map(analyzeHookEvent);
|
|
210
|
+
const passing = analyzed.filter((a) => a.passes_required_fields);
|
|
211
|
+
const required_fields_verified = passing.length > 0;
|
|
212
|
+
const sample_events = passing.slice(-3);
|
|
213
|
+
if (sample_events.length === 0 && analyzed.length > 0) {
|
|
214
|
+
sample_events.push(...analyzed.slice(-2));
|
|
215
|
+
}
|
|
216
|
+
const next_steps = [];
|
|
217
|
+
if (!hooks_json_installed) {
|
|
218
|
+
next_steps.push("Run: aixle-insights init --hooks");
|
|
219
|
+
}
|
|
220
|
+
else if (events.length === 0) {
|
|
221
|
+
next_steps.push("Restart Cursor, then run an Agent session to emit hook events.");
|
|
222
|
+
next_steps.push("Re-run: aixle-insights verify-hooks");
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
captured_at: new Date().toISOString(),
|
|
226
|
+
platform: process.platform,
|
|
227
|
+
hooks_json_installed,
|
|
228
|
+
backup_exists: existsSync(USER_HOOKS_JSON + HOOKS_BACKUP_SUFFIX),
|
|
229
|
+
queue_path_redacted: redactHomePath(queuePath),
|
|
230
|
+
queue_depth: events.length,
|
|
231
|
+
required_fields_verified,
|
|
232
|
+
sample_events,
|
|
233
|
+
next_steps,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-scope Claude Code MCP servers live in ~/.claude.json under top-level `mcpServers`
|
|
3
|
+
* (see Anthropic Claude Code MCP docs: user scope → ~/.claude.json; project scope → .mcp.json).
|
|
4
|
+
*/
|
|
5
|
+
export type InstallResult = {
|
|
6
|
+
kind: "already-configured";
|
|
7
|
+
} | {
|
|
8
|
+
kind: "installed";
|
|
9
|
+
} | {
|
|
10
|
+
kind: "requires-force";
|
|
11
|
+
detail: string;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "error";
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
export interface InstallClaudeUserMcpOptions {
|
|
17
|
+
/** Tests: full path to the Claude user config file (default ~/.claude.json). */
|
|
18
|
+
claudeConfigPath?: string;
|
|
19
|
+
force?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function defaultClaudeUserConfigPath(): string;
|
|
22
|
+
/** Exposed for tests; `platform` defaults to `process.platform`. */
|
|
23
|
+
export declare function desiredAixleInsightsEntry(platform?: NodeJS.Platform): {
|
|
24
|
+
command: string;
|
|
25
|
+
args: string[];
|
|
26
|
+
};
|
|
27
|
+
export declare function aixleInsightsEntryMatchesDesired(existing: unknown, desired: {
|
|
28
|
+
command: string;
|
|
29
|
+
args: string[];
|
|
30
|
+
}): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Merges top-level `mcpServers.aixle-insights` into ~/.claude.json (or overridden path).
|
|
33
|
+
* Preserves all other keys and MCP server entries. If a legacy `mcpServers.db90`
|
|
34
|
+
* entry from a prior install is present, removes it so Claude Code does not
|
|
35
|
+
* spawn both old and new MCP servers (delete-old-key shim).
|
|
36
|
+
*/
|
|
37
|
+
export declare function installClaudeUserMcp(options?: InstallClaudeUserMcpOptions): InstallResult;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
export function defaultClaudeUserConfigPath() {
|
|
6
|
+
const override = process.env["DB90_CLAUDE_USER_CONFIG_PATH"]?.trim();
|
|
7
|
+
if (override)
|
|
8
|
+
return override;
|
|
9
|
+
return join(homedir(), ".claude.json");
|
|
10
|
+
}
|
|
11
|
+
/** Exposed for tests; `platform` defaults to `process.platform`. */
|
|
12
|
+
export function desiredAixleInsightsEntry(platform = process.platform) {
|
|
13
|
+
if (platform === "win32") {
|
|
14
|
+
return {
|
|
15
|
+
command: "cmd",
|
|
16
|
+
args: ["/c", "npx", "-y", "@aixle/insights", "run"],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
command: "npx",
|
|
21
|
+
args: ["-y", "@aixle/insights", "run"],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function readRootObject(path) {
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const raw = readFileSync(path, "utf-8");
|
|
30
|
+
if (!raw.trim()) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
35
|
+
return {
|
|
36
|
+
kind: "error",
|
|
37
|
+
message: `${path}: top-level JSON value must be an object.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
44
|
+
return { kind: "error", message: `Cannot read ${path}: ${msg}` };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function normalizeCommandEntry(e) {
|
|
48
|
+
if (typeof e !== "object" || e === null)
|
|
49
|
+
return null;
|
|
50
|
+
const o = e;
|
|
51
|
+
if (typeof o.command !== "string")
|
|
52
|
+
return null;
|
|
53
|
+
if (!Array.isArray(o.args) || !o.args.every((x) => typeof x === "string"))
|
|
54
|
+
return null;
|
|
55
|
+
return { command: o.command, args: [...o.args] };
|
|
56
|
+
}
|
|
57
|
+
export function aixleInsightsEntryMatchesDesired(existing, desired) {
|
|
58
|
+
const norm = normalizeCommandEntry(existing);
|
|
59
|
+
if (!norm)
|
|
60
|
+
return false;
|
|
61
|
+
if (norm.command !== desired.command || norm.args.length !== desired.args.length)
|
|
62
|
+
return false;
|
|
63
|
+
return norm.args.every((a, i) => a === desired.args[i]);
|
|
64
|
+
}
|
|
65
|
+
function atomicWriteJson(path, data) {
|
|
66
|
+
try {
|
|
67
|
+
const dir = dirname(path);
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
const serialized = `${JSON.stringify(data, null, 2)}\n`;
|
|
70
|
+
const tmpPath = join(dir, `.aixle-insights-claude-json-${randomBytes(8).toString("hex")}.tmp`);
|
|
71
|
+
writeFileSync(tmpPath, serialized, "utf-8");
|
|
72
|
+
renameSync(tmpPath, path);
|
|
73
|
+
return { kind: "installed" };
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
77
|
+
return { kind: "error", message: `Failed to write ${path}: ${msg}` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const LEGACY_MCP_KEY = "db90";
|
|
81
|
+
const AIXLE_INSIGHTS_MCP_KEY = "aixle-insights";
|
|
82
|
+
/**
|
|
83
|
+
* Merges top-level `mcpServers.aixle-insights` into ~/.claude.json (or overridden path).
|
|
84
|
+
* Preserves all other keys and MCP server entries. If a legacy `mcpServers.db90`
|
|
85
|
+
* entry from a prior install is present, removes it so Claude Code does not
|
|
86
|
+
* spawn both old and new MCP servers (delete-old-key shim).
|
|
87
|
+
*/
|
|
88
|
+
export function installClaudeUserMcp(options = {}) {
|
|
89
|
+
const path = options.claudeConfigPath ?? defaultClaudeUserConfigPath();
|
|
90
|
+
const force = options.force === true;
|
|
91
|
+
const desired = desiredAixleInsightsEntry();
|
|
92
|
+
const rootRead = readRootObject(path);
|
|
93
|
+
if ("kind" in rootRead && typeof rootRead.kind === "string" && rootRead.kind === "error") {
|
|
94
|
+
return rootRead;
|
|
95
|
+
}
|
|
96
|
+
const root = {
|
|
97
|
+
...rootRead,
|
|
98
|
+
};
|
|
99
|
+
const rawServers = root["mcpServers"];
|
|
100
|
+
let mcpServers;
|
|
101
|
+
if (rawServers === undefined) {
|
|
102
|
+
mcpServers = {};
|
|
103
|
+
}
|
|
104
|
+
else if (typeof rawServers === "object" &&
|
|
105
|
+
rawServers !== null &&
|
|
106
|
+
!Array.isArray(rawServers)) {
|
|
107
|
+
mcpServers = { ...rawServers };
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
return {
|
|
111
|
+
kind: "error",
|
|
112
|
+
message: `Invalid Claude config at ${path}: "mcpServers" must be an object when present.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const existing = mcpServers[AIXLE_INSIGHTS_MCP_KEY];
|
|
116
|
+
if (existing !== undefined) {
|
|
117
|
+
if (aixleInsightsEntryMatchesDesired(existing, desired)) {
|
|
118
|
+
// Even if the new entry matches, clean up the legacy "db90" key if it
|
|
119
|
+
// somehow still exists (defensive — handles users who manually copied
|
|
120
|
+
// both keys, or partial-state from earlier installs).
|
|
121
|
+
if (mcpServers[LEGACY_MCP_KEY] !== undefined) {
|
|
122
|
+
delete mcpServers[LEGACY_MCP_KEY];
|
|
123
|
+
root["mcpServers"] = mcpServers;
|
|
124
|
+
return atomicWriteJson(path, root);
|
|
125
|
+
}
|
|
126
|
+
return { kind: "already-configured" };
|
|
127
|
+
}
|
|
128
|
+
if (!force) {
|
|
129
|
+
return {
|
|
130
|
+
kind: "requires-force",
|
|
131
|
+
detail: 'A different "aixle-insights" MCP server entry already exists in the Claude Code user config (~/.claude.json). Re-run with `init --force` to replace only that entry.',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Delete-old-key shim: an existing "db90" entry from a prior install must
|
|
136
|
+
// go before we write the new "aixle-insights" entry. Otherwise Claude Code
|
|
137
|
+
// would spawn BOTH MCP servers and we would get duplicate ingestion.
|
|
138
|
+
if (mcpServers[LEGACY_MCP_KEY] !== undefined) {
|
|
139
|
+
delete mcpServers[LEGACY_MCP_KEY];
|
|
140
|
+
}
|
|
141
|
+
mcpServers[AIXLE_INSIGHTS_MCP_KEY] = { command: desired.command, args: desired.args };
|
|
142
|
+
root["mcpServers"] = mcpServers;
|
|
143
|
+
return atomicWriteJson(path, root);
|
|
144
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type InstallClaudeUserMcpOptions, type InstallResult } from "./claude.js";
|
|
2
|
+
export type SupportedEditor = "claude";
|
|
3
|
+
export type { InstallClaudeUserMcpOptions, InstallResult };
|
|
4
|
+
/**
|
|
5
|
+
* Editor dispatch for MCP install hooks. Only Claude Code is supported in this story.
|
|
6
|
+
*/
|
|
7
|
+
export declare function installEditorMcp(editor: SupportedEditor, options?: InstallClaudeUserMcpOptions): InstallResult;
|
|
8
|
+
export { installClaudeUserMcp, defaultClaudeUserConfigPath, desiredAixleInsightsEntry, aixleInsightsEntryMatchesDesired, } from "./claude.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { installClaudeUserMcp, } from "./claude.js";
|
|
2
|
+
/**
|
|
3
|
+
* Editor dispatch for MCP install hooks. Only Claude Code is supported in this story.
|
|
4
|
+
*/
|
|
5
|
+
export function installEditorMcp(editor, options = {}) {
|
|
6
|
+
if (editor === "claude") {
|
|
7
|
+
return installClaudeUserMcp(options);
|
|
8
|
+
}
|
|
9
|
+
return { kind: "error", message: `Unsupported editor for MCP install: ${String(editor)}` };
|
|
10
|
+
}
|
|
11
|
+
export { installClaudeUserMcp, defaultClaudeUserConfigPath, desiredAixleInsightsEntry, aixleInsightsEntryMatchesDesired, } from "./claude.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CLI argument base type. Every connector exposes at minimum these
|
|
3
|
+
* flags; each extends this shape with tool-specific flags (claude adds
|
|
4
|
+
* `--watch` / `--watch-interval`, cursor adds `--since`).
|
|
5
|
+
*
|
|
6
|
+
* The full argv parsing loop stays in each connector's `cli.ts` because the
|
|
7
|
+
* flag sets diverge enough that a generic parser would be more complex than
|
|
8
|
+
* two small switch statements. This type + the helpers below are the shared
|
|
9
|
+
* vocabulary.
|
|
10
|
+
*/
|
|
11
|
+
export interface BaseArgs {
|
|
12
|
+
token?: string;
|
|
13
|
+
host?: string;
|
|
14
|
+
projectId?: string;
|
|
15
|
+
dryRun: boolean;
|
|
16
|
+
verbose: boolean;
|
|
17
|
+
help: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare const BASE_ARGS_DEFAULTS: BaseArgs;
|
|
20
|
+
/**
|
|
21
|
+
* If `arg` is of the form `--name=VALUE`, return VALUE. Otherwise undefined.
|
|
22
|
+
* Callers are responsible for checking the name prefix separately.
|
|
23
|
+
*
|
|
24
|
+
* Example: extractEqualsValue("--token=abc", "--token") === "abc"
|
|
25
|
+
*/
|
|
26
|
+
export declare function extractEqualsValue(arg: string, name: string): string | undefined;
|
package/dist/lib/args.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const BASE_ARGS_DEFAULTS = {
|
|
2
|
+
dryRun: false,
|
|
3
|
+
verbose: false,
|
|
4
|
+
help: false,
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* If `arg` is of the form `--name=VALUE`, return VALUE. Otherwise undefined.
|
|
8
|
+
* Callers are responsible for checking the name prefix separately.
|
|
9
|
+
*
|
|
10
|
+
* Example: extractEqualsValue("--token=abc", "--token") === "abc"
|
|
11
|
+
*/
|
|
12
|
+
export function extractEqualsValue(arg, name) {
|
|
13
|
+
const prefix = `${name}=`;
|
|
14
|
+
if (arg.startsWith(prefix))
|
|
15
|
+
return arg.slice(prefix.length);
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimum payload contract for ingestion — every connector produces events that
|
|
3
|
+
* at least carry an ISO timestamp. Connectors add their own fields; we don't
|
|
4
|
+
* constrain them here so the SDK stays tool-agnostic.
|
|
5
|
+
*/
|
|
6
|
+
export interface IngestPayload {
|
|
7
|
+
occurred_at: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface PostEventOptions {
|
|
11
|
+
/** Override default console.error on non-ok HTTP response. */
|
|
12
|
+
onHttpError?: (status: number, statusText: string, body: string) => void;
|
|
13
|
+
/** Override default console.error on network-level failure. */
|
|
14
|
+
onNetworkError?: (err: unknown) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Called when the server responds with 429 Too Many Requests.
|
|
17
|
+
* @param retryAfter - seconds to wait before retrying (from Retry-After header, capped at 3600).
|
|
18
|
+
* @param quotaExceeded - true when the monthly event quota is exhausted (code: "quota_exceeded").
|
|
19
|
+
*/
|
|
20
|
+
on429?: (retryAfter: number, quotaExceeded: boolean) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* POST a single event payload to the db90 ingest endpoint.
|
|
24
|
+
*
|
|
25
|
+
* Shared HTTP primitive for all connectors. Each connector wraps this in its
|
|
26
|
+
* own batching / watermarking logic (cursor tracks `lastSentAt` across a
|
|
27
|
+
* batch; claude aggregates `sent`/`failed` counts). Result shapes differ by
|
|
28
|
+
* connector, so they are NOT unified here.
|
|
29
|
+
*
|
|
30
|
+
* Returns true on 2xx response, false on any HTTP error or network failure.
|
|
31
|
+
* Never throws — callers can rely on Promise.allSettled-style aggregation.
|
|
32
|
+
*/
|
|
33
|
+
export declare function postEvent(payload: IngestPayload, host: string, token: string, options?: PostEventOptions): Promise<boolean>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST a single event payload to the db90 ingest endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Shared HTTP primitive for all connectors. Each connector wraps this in its
|
|
5
|
+
* own batching / watermarking logic (cursor tracks `lastSentAt` across a
|
|
6
|
+
* batch; claude aggregates `sent`/`failed` counts). Result shapes differ by
|
|
7
|
+
* connector, so they are NOT unified here.
|
|
8
|
+
*
|
|
9
|
+
* Returns true on 2xx response, false on any HTTP error or network failure.
|
|
10
|
+
* Never throws — callers can rely on Promise.allSettled-style aggregation.
|
|
11
|
+
*/
|
|
12
|
+
export async function postEvent(payload, host, token, options = {}) {
|
|
13
|
+
const url = `${host.replace(/\/$/, "")}/api/v1/ingest/events`;
|
|
14
|
+
const headers = {
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
Authorization: `Bearer ${token}`,
|
|
17
|
+
};
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch(url, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers,
|
|
22
|
+
body: JSON.stringify(payload),
|
|
23
|
+
});
|
|
24
|
+
if (response.ok) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (response.status === 429) {
|
|
28
|
+
const raw = response.headers.get("Retry-After") ?? "60";
|
|
29
|
+
const parsed = parseInt(raw, 10);
|
|
30
|
+
const retryAfter = Number.isFinite(parsed) && parsed > 0 ? parsed : 60;
|
|
31
|
+
const bodyJson = await response.json().catch(() => ({}));
|
|
32
|
+
const quotaExceeded = bodyJson?.code === "quota_exceeded";
|
|
33
|
+
options.on429?.(retryAfter, quotaExceeded);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const body = await response.text().catch(() => "");
|
|
37
|
+
const onHttpError = options.onHttpError ??
|
|
38
|
+
((status, statusText, errBody) => {
|
|
39
|
+
console.error(`Failed to post event: HTTP ${status} ${statusText}${errBody ? ` — ${errBody}` : ""}`);
|
|
40
|
+
});
|
|
41
|
+
onHttpError(response.status, response.statusText, body);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
const onNetworkError = options.onNetworkError ??
|
|
46
|
+
((error) => {
|
|
47
|
+
console.error(`Network error posting event: ${error instanceof Error ? error.message : String(error)}`);
|
|
48
|
+
});
|
|
49
|
+
onNetworkError(err);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common config envelope every connector's `config.json` file carries. Pricing
|
|
3
|
+
* is connector-specific (claude = model-keyed, cursor = flat line-cost rates)
|
|
4
|
+
* so it's extracted through a caller-provided parser rather than baked in.
|
|
5
|
+
*/
|
|
6
|
+
export interface BaseConfig {
|
|
7
|
+
token?: string;
|
|
8
|
+
host?: string;
|
|
9
|
+
project_id?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Load a connector's `config.json` from disk. Returns `{}` on missing or
|
|
13
|
+
* malformed files — callers fall back to env vars / CLI flags / defaults.
|
|
14
|
+
*
|
|
15
|
+
* @param configDir Directory containing `config.json`, typically the
|
|
16
|
+
* connector's `APP_DIR` (`~/.db90-claude` / `~/.db90-cursor`).
|
|
17
|
+
* @param parsePricing Optional callback that extracts a connector-specific
|
|
18
|
+
* pricing shape from the raw parsed JSON. Returns
|
|
19
|
+
* `undefined` when the pricing block is missing or invalid.
|
|
20
|
+
* The callback is the extension point for connector-specific
|
|
21
|
+
* validation (claude uses structural checks on a model map;
|
|
22
|
+
* cursor coerces a fixed set of numeric rates).
|
|
23
|
+
*/
|
|
24
|
+
export declare function loadBaseConfig<TPricing = never>(configDir: string, parsePricing?: (raw: Record<string, unknown>) => TPricing | undefined): BaseConfig & {
|
|
25
|
+
pricing?: TPricing;
|
|
26
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Load a connector's `config.json` from disk. Returns `{}` on missing or
|
|
5
|
+
* malformed files — callers fall back to env vars / CLI flags / defaults.
|
|
6
|
+
*
|
|
7
|
+
* @param configDir Directory containing `config.json`, typically the
|
|
8
|
+
* connector's `APP_DIR` (`~/.db90-claude` / `~/.db90-cursor`).
|
|
9
|
+
* @param parsePricing Optional callback that extracts a connector-specific
|
|
10
|
+
* pricing shape from the raw parsed JSON. Returns
|
|
11
|
+
* `undefined` when the pricing block is missing or invalid.
|
|
12
|
+
* The callback is the extension point for connector-specific
|
|
13
|
+
* validation (claude uses structural checks on a model map;
|
|
14
|
+
* cursor coerces a fixed set of numeric rates).
|
|
15
|
+
*/
|
|
16
|
+
export function loadBaseConfig(configDir, parsePricing) {
|
|
17
|
+
const configPath = join(configDir, "config.json");
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
20
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
21
|
+
const obj = parsed;
|
|
22
|
+
const result = {
|
|
23
|
+
token: typeof obj.token === "string" ? obj.token : undefined,
|
|
24
|
+
host: typeof obj.host === "string" ? obj.host : undefined,
|
|
25
|
+
project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
|
|
26
|
+
};
|
|
27
|
+
if (parsePricing) {
|
|
28
|
+
const pricing = parsePricing(obj);
|
|
29
|
+
if (pricing !== undefined)
|
|
30
|
+
result.pricing = pricing;
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// missing or invalid config — fall through
|
|
37
|
+
}
|
|
38
|
+
return {};
|
|
39
|
+
}
|