@aixle/insights 0.2.5-staging → 0.2.6-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 CHANGED
@@ -102,7 +102,27 @@ After `init` succeeds:
102
102
 
103
103
  ## Multi-org
104
104
 
105
- If your account has several organization memberships, `init` mints ingest tokens for the **oldest** membership unless you scope to a different org:
105
+ If your account belongs to a **single** organization, `init` binds that org automatically no flag, zero friction.
106
+
107
+ If your account belongs to **more than one** organization, `init` needs to know which org to bind. It resolves the target in this order:
108
+
109
+ 1. `--organization-id <uuid>` flag (or `DB90_ORGANIZATION_ID` env var), or
110
+ 2. your **Default Organization** preference from the web app.
111
+
112
+ If neither is set, `init` **does not** guess. It prints the organizations you belong to and exits non-zero without saving credentials:
113
+
114
+ ```
115
+ Multiple organizations found — choose one to bind this install:
116
+ - Acme Corp (b1e2... ) — owner
117
+ - Contoso (c3d4... ) — member
118
+ Then either:
119
+ 1. Re-run init with --organization-id <uuid> (completes device login again), or
120
+ 2. Set a Default Organization in web Preferences, then re-run init.
121
+ ```
122
+
123
+ Because credentials are only persisted on success, re-running `init` means completing the Keycloak device login again.
124
+
125
+ To scope explicitly:
106
126
 
107
127
  ```bash
108
128
  npx -y @aixle/insights init \
@@ -113,6 +133,10 @@ npx -y @aixle/insights init \
113
133
 
114
134
  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.
115
135
 
136
+ Once `init` succeeds it reports the bound org (`Credentials saved (organization <uuid>).`), and `aixle-insights health` (or the `aixle_insights_status` MCP tool) shows the bound `organization_id`.
137
+
138
+ > **Preferences caveat — this is non-obvious.** The web app's "current org" (the one you appear to be viewing) is normally the **last-used** org, remembered in your browser's `localStorage`. That is **not** the same as your **Default Organization** preference, which is what `init` reads. A multi-org user who has switched orgs in the UI but never explicitly set **Default Organization** in web Preferences has no server-side preference for `init` to use — so `init` will hit the org-selection error above until you either set the Default Organization preference or pass `--organization-id`.
139
+
116
140
  ## First run and backfill
117
141
 
118
142
  Before you run `init`, the MCP server is a deliberate no-op: it does not read, buffer, or send anything, and it does not create any state files. Nothing is lost during this window — Claude Code's transcripts and Cursor's local telemetry stores are unaffected by whether `@aixle/insights` is watching them.
@@ -1,3 +1,12 @@
1
+ export interface OrgOption {
2
+ id: string;
3
+ name: string;
4
+ role: string;
5
+ }
6
+ export declare class OrganizationSelectionRequiredError extends Error {
7
+ readonly organizations: OrgOption[];
8
+ constructor(message: string, organizations: OrgOption[]);
9
+ }
1
10
  export interface ExchangeAccount {
2
11
  ingestToken: string;
3
12
  }
@@ -1,3 +1,11 @@
1
+ export class OrganizationSelectionRequiredError extends Error {
2
+ organizations;
3
+ constructor(message, organizations) {
4
+ super(message);
5
+ this.name = "OrganizationSelectionRequiredError";
6
+ this.organizations = organizations;
7
+ }
8
+ }
1
9
  export async function exchangeIngestToken(params) {
2
10
  const fetchFn = params.fetchImpl ?? fetch;
3
11
  const requestedTools = params.tools?.length ? [...params.tools] : params.toolName ? [params.toolName] : [];
@@ -38,6 +46,21 @@ export async function exchangeIngestToken(params) {
38
46
  throw new Error(`DB90 exchange: invalid JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
39
47
  }
40
48
  if (!res.ok) {
49
+ const errBody = json;
50
+ if (errBody["error"] === "organization_selection_required") {
51
+ const rawOrgs = Array.isArray(errBody["organizations"]) ? errBody["organizations"] : [];
52
+ // Defensive: skip null/non-object elements so a malformed server body cannot crash init.
53
+ const orgs = rawOrgs
54
+ .filter((o) => typeof o === "object" && o !== null && !Array.isArray(o))
55
+ .filter((o) => typeof o["id"] === "string" && typeof o["name"] === "string")
56
+ .map((o) => ({
57
+ id: o["id"],
58
+ name: o["name"],
59
+ role: String(o["role"] ?? ""),
60
+ }));
61
+ const msg = typeof errBody["message"] === "string" ? errBody["message"] : "Organization selection required.";
62
+ throw new OrganizationSelectionRequiredError(msg, orgs);
63
+ }
41
64
  throw new Error(`DB90 exchange failed (HTTP ${res.status}): ${text.slice(0, 500)}`);
42
65
  }
43
66
  const root = json;
@@ -1,3 +1,4 @@
1
+ import { type OrgOption } from "./exchange.js";
1
2
  import { defaultKeycloakClientId, defaultKeycloakIssuer } from "./keycloak.js";
2
3
  import type { TelemetryToolId } from "./credentials.js";
3
4
  export interface LoginAndPersistOptions {
@@ -29,5 +30,7 @@ export declare function loginAndPersistCredentials(opts: LoginAndPersistOptions)
29
30
  } | {
30
31
  ok: false;
31
32
  error: string;
33
+ code?: "organization_selection_required";
34
+ organizations?: OrgOption[];
32
35
  }>;
33
36
  export { defaultKeycloakIssuer, defaultKeycloakClientId };
package/dist/auth/flow.js CHANGED
@@ -1,4 +1,4 @@
1
- import { exchangeIngestToken } from "./exchange.js";
1
+ import { exchangeIngestToken, OrganizationSelectionRequiredError } from "./exchange.js";
2
2
  import { defaultKeycloakClientId, defaultKeycloakIssuer, obtainKeycloakAccessTokenViaDeviceFlow } from "./keycloak.js";
3
3
  import { loadCredentials, saveStoredCredentials } from "./credentials.js";
4
4
  import { getAppDir } from "../state.js";
@@ -78,10 +78,11 @@ export async function loginAndPersistCredentials(opts) {
78
78
  opts.onSecurityWarning?.(ingestHostSecurity.warning);
79
79
  }
80
80
  const existing = await loadCredentials(appDir);
81
+ const sameOrg = existing?.host === exchanged.ingestHost && existing.organizationId === exchanged.organizationId;
81
82
  const stored = {
82
83
  host: exchanged.ingestHost,
83
84
  organizationId: exchanged.organizationId,
84
- accounts: existing?.host === exchanged.ingestHost ? { ...existing.accounts } : {},
85
+ accounts: sameOrg ? { ...existing.accounts } : {},
85
86
  ...(opts.allowInsecureHttp === true ? { insecureHttpAllowed: true } : {}),
86
87
  };
87
88
  for (const tid of ["claude_code", "cursor"]) {
@@ -92,6 +93,14 @@ export async function loginAndPersistCredentials(opts) {
92
93
  await saveStoredCredentials(stored, appDir);
93
94
  }
94
95
  catch (e) {
96
+ if (e instanceof OrganizationSelectionRequiredError) {
97
+ return {
98
+ ok: false,
99
+ code: "organization_selection_required",
100
+ organizations: e.organizations,
101
+ error: e.message,
102
+ };
103
+ }
95
104
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
96
105
  }
97
106
  return { ok: true, organizationId: exchanged.organizationId };
package/dist/cli.js CHANGED
@@ -185,7 +185,9 @@ init options:
185
185
  --insecure Allow remote http:// hosts for trusted non-production test endpoints only.
186
186
 
187
187
  Multi-org:
188
- Set \`DB90_ORGANIZATION_ID\` to a UUID, or pass \`--organization-id\` on \`init\`, so ingest tokens are minted for that membership instead of the default (oldest) org.
188
+ If you belong to more than one org, pass \`--organization-id <uuid>\` (or set \`DB90_ORGANIZATION_ID\`),
189
+ or set a Default Organization in web Preferences. Without either, \`init\` lists your orgs and exits.
190
+ Single-org users need no flag. Run \`aixle-insights health\` to see the bound organization_id.
189
191
 
190
192
  Credentials:
191
193
  Stored in the OS keychain via keytar when available; otherwise
@@ -270,6 +272,19 @@ export async function runInit(cliArgs, deps) {
270
272
  },
271
273
  });
272
274
  if (!result.ok) {
275
+ if (result.code === "organization_selection_required") {
276
+ runtime.error(result.error);
277
+ if (result.organizations?.length) {
278
+ runtime.error("Multiple organizations found — choose one to bind this install:");
279
+ for (const o of result.organizations) {
280
+ runtime.error(` - ${o.name} (${o.id})${o.role ? ` — ${o.role}` : ""}`);
281
+ }
282
+ }
283
+ runtime.error("Then either:");
284
+ runtime.error(" 1. Re-run init with --organization-id <uuid> (completes device login again), or");
285
+ runtime.error(" 2. Set a Default Organization in web Preferences, then re-run init.");
286
+ return 1;
287
+ }
273
288
  runtime.error(`Auth failed: ${result.error}`);
274
289
  return 1;
275
290
  }
package/dist/health.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface HealthSnapshot {
5
5
  authenticated: boolean;
6
6
  configured: boolean;
7
7
  host: string | null;
8
+ organization_id: string | null;
8
9
  ingest_tools: TelemetryToolId[];
9
10
  app_dir: string;
10
11
  log_path: string;
package/dist/health.js CHANGED
@@ -70,6 +70,7 @@ export async function buildHealthSnapshot() {
70
70
  authenticated: false,
71
71
  configured: false,
72
72
  host: null,
73
+ organization_id: null,
73
74
  ingest_tools: [],
74
75
  app_dir: appDir,
75
76
  log_path: logPath,
@@ -94,6 +95,7 @@ export async function buildHealthSnapshot() {
94
95
  authenticated: true,
95
96
  configured: true,
96
97
  host: creds.host,
98
+ organization_id: creds.organizationId ?? null,
97
99
  ingest_tools,
98
100
  app_dir: appDir,
99
101
  log_path: logPath,
@@ -114,6 +116,7 @@ export async function buildHealthSnapshot() {
114
116
  authenticated: false,
115
117
  configured: false,
116
118
  host: null,
119
+ organization_id: null,
117
120
  ingest_tools: [],
118
121
  app_dir: appDir,
119
122
  log_path: logPath,
@@ -150,6 +153,7 @@ export function healthSnapshotToStatusPayload(snapshot) {
150
153
  authenticated: snapshot.authenticated,
151
154
  configured: snapshot.configured,
152
155
  host: snapshot.host,
156
+ organization_id: snapshot.organization_id,
153
157
  ingest_tools: snapshot.ingest_tools,
154
158
  needs_init: needsInit,
155
159
  onboarding_message: onboardingMessage,
@@ -172,6 +176,7 @@ export function formatHealthForCli(snapshot) {
172
176
  lines.push(`authenticated: ${snapshot.authenticated}`);
173
177
  lines.push(`configured: ${snapshot.configured}`);
174
178
  lines.push(`host: ${snapshot.host ?? "(none)"}`);
179
+ lines.push(`organization_id: ${snapshot.organization_id ?? "(none)"}`);
175
180
  lines.push(`ingest_tools: ${snapshot.ingest_tools.length ? snapshot.ingest_tools.join(", ") : "(none)"}`);
176
181
  lines.push(`state_file_paths:`);
177
182
  if (snapshot.state_file_paths.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aixle/insights",
3
- "version": "0.2.5-staging",
3
+ "version": "0.2.6-staging",
4
4
  "description": "stdio MCP server for AI coding-assistant telemetry — Claude transcript sync + Cursor SQLite ingest.",
5
5
  "type": "module",
6
6
  "bin": {