@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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/dist/auth/credentials.d.ts +23 -0
  4. package/dist/auth/credentials.js +174 -0
  5. package/dist/auth/exchange.d.ts +25 -0
  6. package/dist/auth/exchange.js +87 -0
  7. package/dist/auth/flow.d.ts +24 -0
  8. package/dist/auth/flow.js +66 -0
  9. package/dist/auth/keycloak.d.ts +35 -0
  10. package/dist/auth/keycloak.js +170 -0
  11. package/dist/cli.d.ts +51 -0
  12. package/dist/cli.js +426 -0
  13. package/dist/client.d.ts +28 -0
  14. package/dist/client.js +102 -0
  15. package/dist/collect-cursor-payloads.d.ts +57 -0
  16. package/dist/collect-cursor-payloads.js +134 -0
  17. package/dist/credentials.d.ts +2 -0
  18. package/dist/credentials.js +1 -0
  19. package/dist/cursor-checkpoints.d.ts +12 -0
  20. package/dist/cursor-checkpoints.js +28 -0
  21. package/dist/cursor-config.d.ts +5 -0
  22. package/dist/cursor-config.js +34 -0
  23. package/dist/cursor-payload-contract.d.ts +17 -0
  24. package/dist/cursor-payload-contract.js +258 -0
  25. package/dist/cursor-settings.d.ts +6 -0
  26. package/dist/cursor-settings.js +38 -0
  27. package/dist/cursor-store-audit.d.ts +48 -0
  28. package/dist/cursor-store-audit.js +155 -0
  29. package/dist/daily-stats-versions.d.ts +31 -0
  30. package/dist/daily-stats-versions.js +170 -0
  31. package/dist/health.d.ts +31 -0
  32. package/dist/health.js +195 -0
  33. package/dist/hooks/cursor-hooks-mapper.d.ts +22 -0
  34. package/dist/hooks/cursor-hooks-mapper.js +84 -0
  35. package/dist/hooks/cursor-hooks-reader.d.ts +30 -0
  36. package/dist/hooks/cursor-hooks-reader.js +117 -0
  37. package/dist/hooks/hook-forwarder.mjs +110 -0
  38. package/dist/hooks/hooks-config.d.ts +92 -0
  39. package/dist/hooks/hooks-config.js +235 -0
  40. package/dist/install/claude.d.ts +37 -0
  41. package/dist/install/claude.js +144 -0
  42. package/dist/install/index.d.ts +8 -0
  43. package/dist/install/index.js +11 -0
  44. package/dist/lib/args.d.ts +26 -0
  45. package/dist/lib/args.js +17 -0
  46. package/dist/lib/client.d.ts +33 -0
  47. package/dist/lib/client.js +52 -0
  48. package/dist/lib/config.d.ts +26 -0
  49. package/dist/lib/config.js +39 -0
  50. package/dist/lib/index.d.ts +4 -0
  51. package/dist/lib/index.js +4 -0
  52. package/dist/lib/project-resolver.d.ts +48 -0
  53. package/dist/lib/project-resolver.js +203 -0
  54. package/dist/lock.d.ts +9 -0
  55. package/dist/lock.js +84 -0
  56. package/dist/log.d.ts +14 -0
  57. package/dist/log.js +81 -0
  58. package/dist/pricing.d.ts +40 -0
  59. package/dist/pricing.js +149 -0
  60. package/dist/readers/claude.d.ts +83 -0
  61. package/dist/readers/claude.js +317 -0
  62. package/dist/readers/cursor.d.ts +134 -0
  63. package/dist/readers/cursor.js +900 -0
  64. package/dist/risk-scanner.d.ts +8 -0
  65. package/dist/risk-scanner.js +59 -0
  66. package/dist/server.d.ts +14 -0
  67. package/dist/server.js +234 -0
  68. package/dist/state.d.ts +69 -0
  69. package/dist/state.js +155 -0
  70. package/dist/sync.d.ts +74 -0
  71. package/dist/sync.js +679 -0
  72. package/package.json +66 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dualboot Partners
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # @aixle/insights
2
+
3
+ stdio **MCP server** for AI coding-assistant telemetry. Ingests Claude Code JSONL transcripts and Cursor IDE SQLite telemetry into your organization's ingest API, exposes operator tools on the MCP bridge, and pairs with `aixle-insights init` so teammates can onboard without juggling cron jobs or brittle shell hooks.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # One-shot via npx (recommended — always pulls the latest):
9
+ npx -y @aixle/insights init \
10
+ --host https://YOUR-API-HOST \
11
+ --keycloak-url https://YOUR-KEYCLOAK/realms/YOUR_REALM
12
+
13
+ # Or global install:
14
+ npm i -g @aixle/insights
15
+ aixle-insights init --host https://YOUR-API-HOST --keycloak-url https://YOUR-KEYCLOAK/realms/YOUR_REALM
16
+ ```
17
+
18
+ `init` performs a Keycloak device login, stores credentials locally (OS keychain via `keytar` when available, otherwise `~/.aixle-insights/credentials.json` mode 0600), and merges the MCP server entry into `~/.claude.json` under `mcpServers.aixle-insights`.
19
+
20
+ After `init` succeeds:
21
+
22
+ 1. **Restart Claude Code** — it discovers the new MCP server on the next launch.
23
+ 2. Open Claude Code; confirm `/mcp` lists **aixle-insights**.
24
+ 3. During a Claude session invoke the **`db90_status`** MCP tool (or `aixle-insights health` from the shell) to see connectivity + last sync metadata.
25
+
26
+ ## Multi-org
27
+
28
+ If your account has several organization memberships, `init` mints ingest tokens for the **oldest** membership unless you scope to a different org:
29
+
30
+ ```bash
31
+ npx -y @aixle/insights init \
32
+ --host https://YOUR-API-HOST \
33
+ --keycloak-url https://YOUR-KEYCLOAK/realms/YOUR_REALM \
34
+ --organization-id <uuid>
35
+ ```
36
+
37
+ You can also set `DB90_ORGANIZATION_ID=<uuid>` in your shell environment, or pin it via `mcpServers.aixle-insights.env` in `~/.claude.json`. The CLI flag overrides the env var when both are set.
38
+
39
+ ## Commands
40
+
41
+ | Command | What it does |
42
+ |---|---|
43
+ | `aixle-insights run` | Start the MCP stdio server (the default; spawned by Claude Code on demand). |
44
+ | `aixle-insights run --once` | Perform one multi-tool sync, exit. Useful for cron / manual flushes. |
45
+ | `aixle-insights run --once --full` | Backfill: ignore Cursor watermarks and commit-hash dedupe. |
46
+ | `aixle-insights init` | Keycloak device login + persist credentials + merge `~/.claude.json` entry. |
47
+ | `aixle-insights init --hooks --tool-name cursor` | Also install the Cursor-side hook forwarder (opt-in; requires Cursor restart). |
48
+ | `aixle-insights uninstall-hooks` | Remove the hook forwarder + restore `~/.cursor/hooks.json` backup. |
49
+ | `aixle-insights verify-hooks` | Print hooks install status + queue depth as JSON. |
50
+ | `aixle-insights health` | Multi-line diagnostic (credentials, sync, log path, state files). |
51
+
52
+ ## Environment
53
+
54
+ | Variable | Purpose |
55
+ |---|---|
56
+ | `DB90_API_URL` | API origin for ingestion + MCP exchange (defaults to `http://localhost:3000`; `init --host` overrides). |
57
+ | `KEYCLOAK_ISSUER` / `DB90_KEYCLOAK_ISSUER` | Realm issuer URLs (preferred on servers + CI). |
58
+ | `DB90_KEYCLOAK_CLIENT_ID` | Defaults to `db90-web`; must allow device authorization in Keycloak. |
59
+ | `DB90_ORGANIZATION_ID` | Optional UUID scoping `init` to that org membership (header `X-Organization-ID`). |
60
+ | `AIXLE_INSIGHTS_HOME` | Override the local state directory (defaults to `~/.aixle-insights/`). |
61
+
62
+ Note: the `DB90_*` variables above are retained as compatibility names for the deployment-side Keycloak realm/client identifiers. Future versions may rename them with a deprecation window.
63
+
64
+ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per-model rates):
65
+
66
+ ```json
67
+ {
68
+ "cursor": {
69
+ "line_costs": {
70
+ "claude-sonnet-4-5": { "input_per_line": 0.0002, "output_per_line": 0.0008 }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ ## Cursor hook forwarder (opt-in)
77
+
78
+ `aixle-insights init --hooks --tool-name cursor` installs a Node script as a Cursor hook (`~/.cursor/hooks.json`). The script appends redacted hook payloads to `~/.aixle-insights/hooks-queue.ndjson`; the background sync drains the queue on its next cycle and POSTs the events with accurate per-turn model attribution. Requires a Cursor restart after install. To remove, run `aixle-insights uninstall-hooks` and restart Cursor again.
79
+
80
+ ## State + credentials
81
+
82
+ - **App home directory**: `~/.aixle-insights/` (override with `AIXLE_INSIGHTS_HOME`).
83
+ - **Credentials**: OS keychain via `keytar` when available (service: `aixle-insights`); fallback file `~/.aixle-insights/credentials.json` with mode 0600 on POSIX.
84
+ - **State files**: `state-<hostname>-<token-hash>.json` per credential, plus `state.lock` advisory lock, `mcp.log` rotating diagnostic log, optional `hooks-queue.ndjson`.
85
+
86
+ The internal state-file shape is implementation-detail; don't depend on it from outside this package.
87
+
88
+ ## Diagnostics
89
+
90
+ ```bash
91
+ aixle-insights health # connectivity + last sync metadata
92
+ aixle-insights verify-hooks # JSON: hooks installed + queue depth
93
+ ```
94
+
95
+ `mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`db90_status`** MCP tool returns the same diagnostic structure as `aixle-insights health`.
96
+
97
+ ## Local development — `/aixle-reset` skill
98
+
99
+ > Audience: contributors editing this package. If you installed `@aixle/insights` from npm and aren't modifying the source, you can skip this section.
100
+
101
+ When you iterate on `src/` against a real Claude Code or Cursor session, the running MCP routinely drifts out of sync with the code you just wrote. Common causes:
102
+
103
+ - Rebuilt `dist/` but never restarted the long-lived MCP process — your changes aren't in memory.
104
+ - `~/.claude.json` accumulated both `mcpServers.insights` and `mcpServers.aixle-insights` (or a legacy `mcpServers.db90`), and Claude Code spawned the wrong one.
105
+ - The MCP launched from a non-git-rooted `cwd` (e.g. `~/`), so pre-resolution returned `project_id: null` and every turn in that session inherited it.
106
+
107
+ The repo ships a Claude Code skill, **`/aixle-reset`** (lives at `.claude/skills/aixle-reset/SKILL.md`), that restores known-good local state in one shot.
108
+
109
+ It runs `scripts/reset-local-env.mjs`, which:
110
+
111
+ 1. Stops any running `aixle-insights/dist/cli.js run` process.
112
+ 2. Rebuilds `dist/` if anything in `src/` is newer.
113
+ 3. Repairs `~/.claude.json` and `~/.cursor/mcp.json` so the entries point at `node <abs path>/dist/cli.js run`, stripping duplicate `insights` and legacy `db90` keys. Backs up each file it modifies.
114
+ 4. Warns non-blockingly about leftover direct-curl ingest hooks in `~/.claude/settings.json` and stale `@db90/*` npm globals.
115
+ 5. Restarts the MCP from the repo root so pre-resolution finds the project from `git remote`.
116
+ 6. Waits up to 30 s for the first `project_attribution_resolved` log line and asserts `project_id` is non-null.
117
+
118
+ Invoke manually:
119
+
120
+ ```bash
121
+ node packages/tools/aixle-insights/scripts/reset-local-env.mjs
122
+ ```
123
+
124
+ Or, inside Claude Code, run the **`/aixle-reset`** skill.
125
+
126
+ A `PostToolUse` hook (`.claude/hooks/on-aixle-insights-edit.ts`) **suggests** running the skill whenever you edit a file under `packages/tools/aixle-insights/**` (excluding `dist/`, `*.md`, and any test path). The hook is advisory only — it never auto-executes, so you can iterate freely and reset when you're ready to test end-to-end.
127
+
128
+ After the script reports success: **quit and reopen Claude Code / Cursor** so each IDE re-spawns its own MCP from the now-canonical config. The MCP the script started manually keeps running standalone and will still sweep transcripts correctly until you replace it.
129
+
130
+ ## Requirements
131
+
132
+ - Node.js ≥ 20.
133
+ - macOS / Linux / Windows. On Windows, the package writes a `cmd /c npx …` wrapper in `~/.claude.json` so Claude Code can spawn the MCP server reliably.
134
+
135
+ ## License
136
+
137
+ MIT.
@@ -0,0 +1,23 @@
1
+ /** Ingest tool IDs that MCP can provision tokens for via `/integrations/mcp/exchange`. */
2
+ export type TelemetryToolId = "claude_code" | "cursor";
3
+ /** Normalized persisted credentials (v2); all accounts share one ingest host namespace. */
4
+ export interface StoredCredentials {
5
+ host: string;
6
+ organizationId?: string;
7
+ accounts: Partial<Record<TelemetryToolId, string>>;
8
+ }
9
+ export declare const KEYTAR_SERVICE = "aixle-insights";
10
+ /** Returns true when at least one tool has a non-empty token. */
11
+ export declare function credentialsHaveAnyToken(creds: StoredCredentials): boolean;
12
+ export declare function pickProjectLookupToken(creds: StoredCredentials): string | null;
13
+ /** Read credentials from disk only (tests / fallback). */
14
+ export declare function loadCredentialsFromFileOnly(appDir?: string): StoredCredentials | null;
15
+ /** Prefer OS keychain when keytar works; otherwise read `credentials.json`. */
16
+ export declare function loadCredentials(appDir?: string): Promise<StoredCredentials | null>;
17
+ /**
18
+ * Persist multi-tool ingest tokens for one host namespace.
19
+ */
20
+ export declare function saveStoredCredentials(creds: StoredCredentials, appDir?: string): Promise<void>;
21
+ /** Back-compat: persists a Claude-only ingest token bundle. */
22
+ export declare function saveCredentials(token: string, host: string, appDir?: string): Promise<void>;
23
+ export declare function clearCredentials(appDir?: string): Promise<void>;
@@ -0,0 +1,174 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAppDir } from "../state.js";
4
+ export const KEYTAR_SERVICE = "aixle-insights";
5
+ const KEYTAR_ACCOUNT = "aixle-insights-ingest-credential";
6
+ function credentialsPath(appDir) {
7
+ return join(appDir, "credentials.json");
8
+ }
9
+ function keytarDisabled() {
10
+ return ["1", "true", "yes"].includes(process.env["DB90_MCP_DISABLE_KEYTAR"]?.toLowerCase() ?? "");
11
+ }
12
+ /** Returns true when at least one tool has a non-empty token. */
13
+ export function credentialsHaveAnyToken(creds) {
14
+ const acc = creds.accounts;
15
+ if (acc === null || acc === undefined || typeof acc !== "object")
16
+ return false;
17
+ return Object.values(acc).some((t) => typeof t === "string" && t.length > 0);
18
+ }
19
+ export function pickProjectLookupToken(creds) {
20
+ return creds.accounts.claude_code ?? creds.accounts.cursor ?? null;
21
+ }
22
+ function normalizeLoadedCredentials(raw) {
23
+ if (typeof raw !== "object" || raw === null)
24
+ return null;
25
+ const o = raw;
26
+ const host = o.host;
27
+ const v = o.version;
28
+ if (v === 2) {
29
+ const acc = o.accounts;
30
+ if (typeof host !== "string" || host.length === 0)
31
+ return null;
32
+ if (typeof acc !== "object" || acc === null)
33
+ return null;
34
+ const accounts = acc;
35
+ const out = {};
36
+ for (const tid of ["claude_code", "cursor"]) {
37
+ const tok = accounts[tid];
38
+ if (typeof tok === "string" && tok.length > 0)
39
+ out[tid] = tok;
40
+ }
41
+ if (!credentialsHaveAnyToken({ host, accounts: out }))
42
+ return null;
43
+ const org = o.organizationId;
44
+ return {
45
+ host,
46
+ accounts: out,
47
+ organizationId: typeof org === "string" ? org : undefined,
48
+ };
49
+ }
50
+ const token = o.token;
51
+ if (typeof token === "string" && token.length > 0 && typeof host === "string" && host.length > 0) {
52
+ return { host, accounts: { claude_code: token } };
53
+ }
54
+ return null;
55
+ }
56
+ /** Read credentials from disk only (tests / fallback). */
57
+ export function loadCredentialsFromFileOnly(appDir = getAppDir()) {
58
+ const filePath = credentialsPath(appDir);
59
+ if (!existsSync(filePath))
60
+ return null;
61
+ try {
62
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
63
+ return normalizeLoadedCredentials(raw);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ async function tryKeytarGet() {
70
+ if (keytarDisabled())
71
+ return null;
72
+ try {
73
+ const keytar = await import("keytar");
74
+ const raw = await keytar.default.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
75
+ if (!raw)
76
+ return null;
77
+ const parsed = JSON.parse(raw);
78
+ return normalizeLoadedCredentials(parsed);
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ async function tryKeytarSet(payload) {
85
+ if (keytarDisabled())
86
+ return false;
87
+ try {
88
+ const keytar = await import("keytar");
89
+ await keytar.default.setPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT, payload);
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ async function tryKeytarDelete() {
97
+ if (keytarDisabled())
98
+ return;
99
+ try {
100
+ const keytar = await import("keytar");
101
+ await keytar.default.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
102
+ }
103
+ catch {
104
+ // ignore
105
+ }
106
+ }
107
+ function writeFileCredential(appDir, creds) {
108
+ mkdirSync(appDir, { recursive: true });
109
+ const filePath = credentialsPath(appDir);
110
+ const body = {
111
+ version: 2,
112
+ host: creds.host,
113
+ organizationId: creds.organizationId,
114
+ accounts: { ...creds.accounts },
115
+ };
116
+ writeFileSync(filePath, `${JSON.stringify(body, null, 2)}\n`, {
117
+ encoding: "utf-8",
118
+ mode: 0o600,
119
+ });
120
+ if (process.platform !== "win32") {
121
+ try {
122
+ chmodSync(filePath, 0o600);
123
+ }
124
+ catch {
125
+ // ignore chmod failures on exotic FS
126
+ }
127
+ }
128
+ }
129
+ function removeFileCredential(appDir) {
130
+ const filePath = credentialsPath(appDir);
131
+ if (existsSync(filePath)) {
132
+ try {
133
+ unlinkSync(filePath);
134
+ }
135
+ catch {
136
+ // ignore
137
+ }
138
+ }
139
+ }
140
+ /** Prefer OS keychain when keytar works; otherwise read `credentials.json`. */
141
+ export async function loadCredentials(appDir = getAppDir()) {
142
+ const fromFile = loadCredentialsFromFileOnly(appDir);
143
+ if (fromFile)
144
+ return fromFile;
145
+ const fromKeytar = await tryKeytarGet();
146
+ if (fromKeytar)
147
+ return fromKeytar;
148
+ return null;
149
+ }
150
+ /**
151
+ * Persist multi-tool ingest tokens for one host namespace.
152
+ */
153
+ export async function saveStoredCredentials(creds, appDir = getAppDir()) {
154
+ if (!credentialsHaveAnyToken(creds)) {
155
+ throw new Error("saveStoredCredentials requires at least one account token");
156
+ }
157
+ const payload = JSON.stringify({ version: 2, ...creds, accounts: { ...creds.accounts } });
158
+ const keytarOk = await tryKeytarSet(payload);
159
+ if (keytarOk) {
160
+ removeFileCredential(appDir);
161
+ }
162
+ else {
163
+ await tryKeytarDelete();
164
+ writeFileCredential(appDir, creds);
165
+ }
166
+ }
167
+ /** Back-compat: persists a Claude-only ingest token bundle. */
168
+ export async function saveCredentials(token, host, appDir = getAppDir()) {
169
+ await saveStoredCredentials({ host, accounts: { claude_code: token } }, appDir);
170
+ }
171
+ export async function clearCredentials(appDir = getAppDir()) {
172
+ await tryKeytarDelete();
173
+ removeFileCredential(appDir);
174
+ }
@@ -0,0 +1,25 @@
1
+ export interface ExchangeAccount {
2
+ ingestToken: string;
3
+ }
4
+ export interface ExchangeResult {
5
+ ingestHost: string;
6
+ organizationId: string;
7
+ /** Present when exchanging a single tool (legacy shape). */
8
+ ingestToken?: string;
9
+ /** Per-tool ingest tokens (`claude_code`, `cursor`). */
10
+ accounts: Partial<Record<"claude_code" | "cursor", ExchangeAccount>>;
11
+ }
12
+ type ExchangeToolId = "claude_code" | "cursor";
13
+ export declare function exchangeIngestToken(params: {
14
+ db90Host: string;
15
+ keycloakAccessToken: string;
16
+ /** Legacy single-tool mint. Omit when tools is provided. */
17
+ toolName?: ExchangeToolId;
18
+ /** Mint / rotate ingest tokens for all listed tools under one OAuth session. */
19
+ tools?: readonly ExchangeToolId[];
20
+ deviceLabel?: string;
21
+ /** When set, sent as `X-Organization-ID` so the API scopes exchange to that membership. */
22
+ exchangeOrganizationId?: string;
23
+ fetchImpl?: typeof fetch;
24
+ }): Promise<ExchangeResult>;
25
+ export {};
@@ -0,0 +1,87 @@
1
+ export async function exchangeIngestToken(params) {
2
+ const fetchFn = params.fetchImpl ?? fetch;
3
+ const requestedTools = params.tools?.length ? [...params.tools] : params.toolName ? [params.toolName] : [];
4
+ const base = params.db90Host.replace(/\/$/, "");
5
+ const url = `${base}/api/v1/integrations/mcp/exchange`;
6
+ const body = {};
7
+ if (params.tools?.length) {
8
+ body.tools = [...params.tools];
9
+ }
10
+ else if (params.toolName) {
11
+ body.tool_name = params.toolName;
12
+ }
13
+ else {
14
+ throw new Error("exchangeIngestToken: provide toolName or tools[]");
15
+ }
16
+ if (params.deviceLabel) {
17
+ body.device_label = params.deviceLabel;
18
+ }
19
+ const headers = {
20
+ "Content-Type": "application/json",
21
+ Authorization: `Bearer ${params.keycloakAccessToken}`,
22
+ };
23
+ const orgHeader = params.exchangeOrganizationId?.trim();
24
+ if (orgHeader) {
25
+ headers["X-Organization-ID"] = orgHeader;
26
+ }
27
+ const res = await fetchFn(url, {
28
+ method: "POST",
29
+ headers,
30
+ body: JSON.stringify(body),
31
+ });
32
+ const text = await res.text();
33
+ let json;
34
+ try {
35
+ json = JSON.parse(text);
36
+ }
37
+ catch {
38
+ throw new Error(`DB90 exchange: invalid JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
39
+ }
40
+ if (!res.ok) {
41
+ throw new Error(`DB90 exchange failed (HTTP ${res.status}): ${text.slice(0, 500)}`);
42
+ }
43
+ const root = json;
44
+ const data = root["data"];
45
+ if (typeof data !== "object" || data === null) {
46
+ throw new Error("DB90 exchange: missing data object");
47
+ }
48
+ const d = data;
49
+ const ingestHost = d["ingestHost"];
50
+ const organizationId = d["organizationId"];
51
+ if (typeof ingestHost !== "string" || typeof organizationId !== "string") {
52
+ throw new Error("DB90 exchange: missing ingestHost or organizationId in data");
53
+ }
54
+ const ingestTokenRaw = d["ingestToken"];
55
+ const accountsRaw = d["accounts"];
56
+ const accounts = {};
57
+ if (typeof accountsRaw === "object" && accountsRaw !== null && !Array.isArray(accountsRaw)) {
58
+ for (const key of ["claude_code", "cursor"]) {
59
+ const entry = accountsRaw[key];
60
+ if (typeof entry === "object" && entry !== null) {
61
+ const tok = entry["ingestToken"];
62
+ if (typeof tok === "string" && tok.length > 0) {
63
+ accounts[key] = { ingestToken: tok };
64
+ }
65
+ }
66
+ }
67
+ }
68
+ let ingestToken = typeof ingestTokenRaw === "string" && ingestTokenRaw.length > 0 ? ingestTokenRaw : undefined;
69
+ if (!ingestToken && Object.keys(accounts).length === 1) {
70
+ const only = Object.values(accounts)[0];
71
+ if (only?.ingestToken)
72
+ ingestToken = only.ingestToken;
73
+ }
74
+ if (Object.keys(accounts).length === 0) {
75
+ if (!ingestToken) {
76
+ throw new Error("DB90 exchange: no ingestToken / accounts returned in data");
77
+ }
78
+ const fallbackTool = requestedTools.length === 1 ? requestedTools[0] : d["toolName"];
79
+ const tid = fallbackTool === "cursor" ? "cursor" : "claude_code";
80
+ accounts[tid] = { ingestToken };
81
+ }
82
+ const missingTools = requestedTools.filter((tool) => !accounts[tool]?.ingestToken);
83
+ if (missingTools.length > 0) {
84
+ throw new Error(`DB90 exchange: missing requested account(s): ${missingTools.join(", ")}`);
85
+ }
86
+ return { ingestHost, organizationId, ingestToken, accounts };
87
+ }
@@ -0,0 +1,24 @@
1
+ import { defaultKeycloakClientId, defaultKeycloakIssuer } from "./keycloak.js";
2
+ import type { TelemetryToolId } from "./credentials.js";
3
+ export interface LoginAndPersistOptions {
4
+ db90Host: string;
5
+ keycloakIssuer: string;
6
+ /** @deprecated Prefer `tools`; kept for callers that mint a single ingest account. */
7
+ toolName?: string;
8
+ tools?: TelemetryToolId[];
9
+ deviceLabel?: string;
10
+ /** Optional org UUID for `X-Organization-ID` on MCP exchange (multi-org users). */
11
+ exchangeOrganizationId?: string;
12
+ clientId?: string;
13
+ appDir?: string;
14
+ onVisitInstructions?: (verification_uri: string, user_code: string) => void;
15
+ fetchImpl?: typeof fetch;
16
+ }
17
+ export declare function loginAndPersistCredentials(opts: LoginAndPersistOptions): Promise<{
18
+ ok: true;
19
+ organizationId: string;
20
+ } | {
21
+ ok: false;
22
+ error: string;
23
+ }>;
24
+ export { defaultKeycloakIssuer, defaultKeycloakClientId };
@@ -0,0 +1,66 @@
1
+ import { exchangeIngestToken } from "./exchange.js";
2
+ import { defaultKeycloakClientId, defaultKeycloakIssuer, obtainKeycloakAccessTokenViaDeviceFlow } from "./keycloak.js";
3
+ import { loadCredentials, saveStoredCredentials } from "./credentials.js";
4
+ import { getAppDir } from "../state.js";
5
+ export async function loginAndPersistCredentials(opts) {
6
+ const issuer = opts.keycloakIssuer.trim();
7
+ if (!issuer) {
8
+ return { ok: false, error: "Keycloak issuer is empty; set KEYCLOAK_ISSUER or pass --keycloak-url." };
9
+ }
10
+ const clientId = opts.clientId?.trim() || defaultKeycloakClientId();
11
+ const appDir = opts.appDir ?? getAppDir();
12
+ const requestedTools = opts.tools ??
13
+ ((opts.toolName ?? "claude_code") === "cursor" ? ["cursor"] : ["claude_code"]);
14
+ let accessToken;
15
+ try {
16
+ accessToken = await obtainKeycloakAccessTokenViaDeviceFlow({
17
+ issuer,
18
+ clientId,
19
+ onInstructions: opts.onVisitInstructions,
20
+ fetchImpl: opts.fetchImpl,
21
+ });
22
+ }
23
+ catch (e) {
24
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
25
+ }
26
+ let exchanged;
27
+ try {
28
+ if (requestedTools.length > 1) {
29
+ exchanged = await exchangeIngestToken({
30
+ db90Host: opts.db90Host,
31
+ keycloakAccessToken: accessToken,
32
+ tools: requestedTools,
33
+ deviceLabel: opts.deviceLabel,
34
+ exchangeOrganizationId: opts.exchangeOrganizationId,
35
+ fetchImpl: opts.fetchImpl,
36
+ });
37
+ }
38
+ else {
39
+ exchanged = await exchangeIngestToken({
40
+ db90Host: opts.db90Host,
41
+ keycloakAccessToken: accessToken,
42
+ toolName: requestedTools[0],
43
+ deviceLabel: opts.deviceLabel,
44
+ exchangeOrganizationId: opts.exchangeOrganizationId,
45
+ fetchImpl: opts.fetchImpl,
46
+ });
47
+ }
48
+ const existing = await loadCredentials(appDir);
49
+ const stored = {
50
+ host: exchanged.ingestHost,
51
+ organizationId: exchanged.organizationId,
52
+ accounts: existing?.host === exchanged.ingestHost ? { ...existing.accounts } : {},
53
+ };
54
+ for (const tid of ["claude_code", "cursor"]) {
55
+ const acc = exchanged.accounts[tid];
56
+ if (acc?.ingestToken)
57
+ stored.accounts[tid] = acc.ingestToken;
58
+ }
59
+ await saveStoredCredentials(stored, appDir);
60
+ }
61
+ catch (e) {
62
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
63
+ }
64
+ return { ok: true, organizationId: exchanged.organizationId };
65
+ }
66
+ export { defaultKeycloakIssuer, defaultKeycloakClientId };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * RFC 8628 OAuth 2.0 Device Authorization Grant against Keycloak OIDC endpoints.
3
+ */
4
+ export interface DeviceAuthorizationStart {
5
+ device_code: string;
6
+ user_code: string;
7
+ verification_uri: string;
8
+ verification_uri_complete?: string;
9
+ expires_in: number;
10
+ interval?: number;
11
+ code_verifier?: string;
12
+ }
13
+ export declare function startDeviceAuthorization(params: {
14
+ issuer: string;
15
+ clientId: string;
16
+ scope?: string;
17
+ fetchImpl?: typeof fetch;
18
+ }): Promise<DeviceAuthorizationStart>;
19
+ export declare function pollDeviceAccessToken(params: {
20
+ issuer: string;
21
+ clientId: string;
22
+ deviceAuthorization: DeviceAuthorizationStart;
23
+ /** Called once before polling so UIs can show the visit URL and user code. */
24
+ onInstructions?: (verification_uri: string, user_code: string) => void;
25
+ fetchImpl?: typeof fetch;
26
+ }): Promise<string>;
27
+ export declare function obtainKeycloakAccessTokenViaDeviceFlow(params: {
28
+ issuer: string;
29
+ clientId: string;
30
+ scope?: string;
31
+ onInstructions?: (verification_uri: string, user_code: string) => void;
32
+ fetchImpl?: typeof fetch;
33
+ }): Promise<string>;
34
+ export declare function defaultKeycloakIssuer(): string;
35
+ export declare function defaultKeycloakClientId(): string;