@nexalink/carecompass-sdk 0.2.0-beta.15 → 0.4.0-beta.15

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/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { randomUUID } from "node:crypto";
3
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
4
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { createDefaultCareCompassConfig, validateCareCompassConfig, } from "./schema.js";
6
+ import { careCompassApiBaseUrlSchema, createDefaultCareCompassConfig, validateCareCompassConfig, } from "./schema.js";
7
7
  import { normalizeApiKey, resolveApiKey, resolveCredentialsPath, writeStoredApiKey, } from "./credentials.js";
8
8
  import { generateCareCompassIframeSnippet, generateCareCompassSnippet, } from "./snippet.js";
9
9
  import { CARECOMPASS_TELEMETRY_ERROR_CODES, CARECOMPASS_TELEMETRY_EVENTS, createCareCompassCliTelemetry, } from "./telemetry.js";
@@ -31,6 +31,277 @@ support resources, and appropriate next steps. Stay out of diagnosis, treatment
31
31
  planning, emergency triage, legal advice, and coverage guarantees. When a member
32
32
  needs clinical, emergency, plan-specific, or account-specific help that requires
33
33
  a human, route them to the customer's approved support resources.`;
34
+ const CLI_COMMAND_ALLOWLIST = new Set([
35
+ "auth",
36
+ "completion",
37
+ "config",
38
+ "configure",
39
+ "doctor",
40
+ "embed",
41
+ "health",
42
+ "help",
43
+ "init",
44
+ "login",
45
+ "logout",
46
+ "query",
47
+ "setup",
48
+ "snippet",
49
+ "validate",
50
+ "version",
51
+ ]);
52
+ const UNAUTHENTICATED_AUTH_ATTEMPT_LIMIT = 2;
53
+ const UNAUTHENTICATED_AUTH_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
54
+ const UNAUTHENTICATED_AUTH_RATE_LIMIT_FILE = "auth-rate-limit.json";
55
+ class CliTelemetryError extends Error {
56
+ constructor(message, telemetry) {
57
+ super(message);
58
+ this.telemetry = telemetry;
59
+ }
60
+ }
61
+ const sanitizeCommandName = (command) => {
62
+ const normalized = command === "--help" || command === "-h" ? "help" : command;
63
+ if (command === "--version" || command === "-v")
64
+ return "version";
65
+ return CLI_COMMAND_ALLOWLIST.has(normalized) ? normalized : "unknown_command";
66
+ };
67
+ const authTelemetryForSource = (source) => {
68
+ if (source === "environment") {
69
+ return { authMethod: "env_var", keySource: "env_var" };
70
+ }
71
+ if (source === "file") {
72
+ return { authMethod: "config_file", keySource: "config_file" };
73
+ }
74
+ return { authMethod: "api_key", keySource: "cli_flag" };
75
+ };
76
+ const unauthenticatedRateLimitPath = (io) => io.env.CARECOMPASS_AUTH_RATE_LIMIT_PATH ??
77
+ join(dirname(resolveCredentialsPath(io.env)), UNAUTHENTICATED_AUTH_RATE_LIMIT_FILE);
78
+ const unauthenticatedRateLimitKey = (io) => {
79
+ const keyMaterial = io.env.CARECOMPASS_AUTH_RATE_LIMIT_KEY
80
+ ? `explicit:${io.env.CARECOMPASS_AUTH_RATE_LIMIT_KEY}`
81
+ : io.env.CARECOMPASS_CLIENT_IP
82
+ ? `ip:${io.env.CARECOMPASS_CLIENT_IP}`
83
+ : `credentials:${resolveCredentialsPath(io.env)}`;
84
+ return createHash("sha256")
85
+ .update("carecompass unauthenticated auth rate limit")
86
+ .update(keyMaterial)
87
+ .digest("hex");
88
+ };
89
+ const emptyUnauthenticatedAuthLedger = () => ({
90
+ version: 1,
91
+ entries: {},
92
+ });
93
+ const readUnauthenticatedAuthLedger = (path) => {
94
+ try {
95
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
96
+ if (parsed.version !== 1 || typeof parsed.entries !== "object") {
97
+ return emptyUnauthenticatedAuthLedger();
98
+ }
99
+ const entries = {};
100
+ for (const [key, value] of Object.entries(parsed.entries)) {
101
+ if (typeof value === "object" &&
102
+ value !== null &&
103
+ typeof value.failures ===
104
+ "number" &&
105
+ typeof value.firstAttemptAt ===
106
+ "number" &&
107
+ typeof value.lastAttemptAt ===
108
+ "number") {
109
+ entries[key] = value;
110
+ }
111
+ }
112
+ return { version: 1, entries };
113
+ }
114
+ catch {
115
+ return emptyUnauthenticatedAuthLedger();
116
+ }
117
+ };
118
+ const writeUnauthenticatedAuthLedger = (path, ledger) => {
119
+ mkdirSync(dirname(path), { recursive: true });
120
+ writeFileSync(path, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
121
+ try {
122
+ chmodSync(path, 0o600);
123
+ }
124
+ catch {
125
+ // Some filesystems do not support chmod; the ledger contains only hashed
126
+ // limiter keys and counters.
127
+ }
128
+ };
129
+ const pruneUnauthenticatedAuthLedger = (ledger, now) => {
130
+ for (const [key, entry] of Object.entries(ledger.entries)) {
131
+ if (now - entry.firstAttemptAt >
132
+ UNAUTHENTICATED_AUTH_RATE_LIMIT_WINDOW_MS) {
133
+ delete ledger.entries[key];
134
+ }
135
+ }
136
+ };
137
+ const recordUnauthenticatedAuthFailure = (io) => {
138
+ const path = unauthenticatedRateLimitPath(io);
139
+ const key = unauthenticatedRateLimitKey(io);
140
+ const now = Date.now();
141
+ const ledger = readUnauthenticatedAuthLedger(path);
142
+ pruneUnauthenticatedAuthLedger(ledger, now);
143
+ const existing = ledger.entries[key];
144
+ const entry = existing &&
145
+ now - existing.firstAttemptAt <= UNAUTHENTICATED_AUTH_RATE_LIMIT_WINDOW_MS
146
+ ? existing
147
+ : { failures: 0, firstAttemptAt: now, lastAttemptAt: now };
148
+ entry.failures += 1;
149
+ entry.lastAttemptAt = now;
150
+ ledger.entries[key] = entry;
151
+ writeUnauthenticatedAuthLedger(path, ledger);
152
+ const failures = entry.failures;
153
+ if (failures < UNAUTHENTICATED_AUTH_ATTEMPT_LIMIT)
154
+ return null;
155
+ return new CliTelemetryError("Too many failed authentication attempts. Wait before trying again or verify the API key in the Nexalink console.", {
156
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.RATE_LIMIT_EXCEEDED,
157
+ failureCategory: "rate_limit_error",
158
+ });
159
+ };
160
+ const resetUnauthenticatedAuthFailures = (io) => {
161
+ const path = unauthenticatedRateLimitPath(io);
162
+ const ledger = readUnauthenticatedAuthLedger(path);
163
+ delete ledger.entries[unauthenticatedRateLimitKey(io)];
164
+ writeUnauthenticatedAuthLedger(path, ledger);
165
+ };
166
+ const normalizeBaaStatus = (value) => {
167
+ if (value === "signed" || value === "pending" || value === "not_required") {
168
+ return value;
169
+ }
170
+ return "signed";
171
+ };
172
+ const classifyAuthFailure = (status, message) => {
173
+ const normalized = message.toLowerCase();
174
+ if (status === 429 || normalized.includes("rate limit")) {
175
+ return {
176
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.RATE_LIMIT_EXCEEDED,
177
+ failureCategory: "rate_limit_error",
178
+ baaStatus: "signed",
179
+ };
180
+ }
181
+ if (status === 403 ||
182
+ normalized.includes("baa") ||
183
+ normalized.includes("business associate")) {
184
+ return {
185
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.BAA_REQUIRED,
186
+ failureCategory: "baa_not_signed",
187
+ baaStatus: "pending",
188
+ };
189
+ }
190
+ if (normalized.includes("expired")) {
191
+ return {
192
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.AUTH_KEY_EXPIRED,
193
+ failureCategory: "auth_error",
194
+ baaStatus: "signed",
195
+ };
196
+ }
197
+ return {
198
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.AUTH_KEY_INVALID,
199
+ failureCategory: "auth_error",
200
+ baaStatus: "signed",
201
+ };
202
+ };
203
+ const classifyCliFailure = (error) => {
204
+ if (error instanceof CliTelemetryError)
205
+ return error.telemetry;
206
+ const code = error && typeof error === "object" && "code" in error
207
+ ? String(error.code)
208
+ : "";
209
+ const message = error instanceof Error ? error.message : String(error);
210
+ const normalized = message.toLowerCase();
211
+ if (code === "EACCES" || code === "EPERM") {
212
+ return {
213
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.PERMISSION_DENIED,
214
+ failureCategory: "permission_error",
215
+ };
216
+ }
217
+ if (code === "ENOENT" || normalized.includes("was not found")) {
218
+ return {
219
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CONFIG_NOT_FOUND,
220
+ failureCategory: "config_error",
221
+ };
222
+ }
223
+ if (normalized.includes("invalid carecompass config") ||
224
+ normalized.includes("unexpected argument") ||
225
+ normalized.includes("must be either") ||
226
+ normalized.includes("api base url")) {
227
+ return {
228
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CONFIG_INVALID,
229
+ failureCategory: "config_error",
230
+ };
231
+ }
232
+ if (normalized.includes("already exists")) {
233
+ return {
234
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CONFIG_ALREADY_EXISTS,
235
+ failureCategory: "config_error",
236
+ };
237
+ }
238
+ if (normalized.includes("missing nexalink api key")) {
239
+ return {
240
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.AUTH_KEY_MISSING,
241
+ failureCategory: "auth_error",
242
+ };
243
+ }
244
+ if (normalized.includes("api key format") ||
245
+ normalized.includes("invalid api key")) {
246
+ return {
247
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.AUTH_KEY_INVALID,
248
+ failureCategory: "auth_error",
249
+ };
250
+ }
251
+ if (normalized.includes("baa execution") ||
252
+ normalized.includes("business associate")) {
253
+ return {
254
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.BAA_REQUIRED,
255
+ failureCategory: "baa_not_signed",
256
+ };
257
+ }
258
+ if (normalized.includes("rate limit") || normalized.includes("quota")) {
259
+ return {
260
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.RATE_LIMIT_EXCEEDED,
261
+ failureCategory: "rate_limit_error",
262
+ };
263
+ }
264
+ if (normalized.includes("timed out") || normalized.includes("timeout")) {
265
+ return {
266
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.NET_TIMEOUT,
267
+ failureCategory: "network_error",
268
+ };
269
+ }
270
+ if (normalized.includes("could not reach")) {
271
+ return {
272
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.NET_UNREACHABLE,
273
+ failureCategory: "network_error",
274
+ };
275
+ }
276
+ if (normalized.includes("model load")) {
277
+ return {
278
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.MODEL_LOAD_FAILED,
279
+ failureCategory: "model_error",
280
+ };
281
+ }
282
+ if (normalized.includes("model") || normalized.includes("inference")) {
283
+ return {
284
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.MODEL_INFERENCE_FAILED,
285
+ failureCategory: "model_error",
286
+ };
287
+ }
288
+ if (normalized.includes("unknown command")) {
289
+ return {
290
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CLI_COMMAND_UNKNOWN,
291
+ failureCategory: "unknown",
292
+ };
293
+ }
294
+ if (normalized.includes("cancelled")) {
295
+ return {
296
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CLI_COMMAND_CANCELLED,
297
+ failureCategory: "unknown",
298
+ };
299
+ }
300
+ return {
301
+ errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.UNKNOWN_ERROR,
302
+ failureCategory: "unknown",
303
+ };
304
+ };
34
305
  function parseArgs(argv) {
35
306
  const [command = "help", ...rest] = argv;
36
307
  const flags = {};
@@ -210,6 +481,12 @@ const signupUrls = (flags, io) => ({
210
481
  io.env.CARECOMPASS_DEVELOPER_SIGNUP_URL ??
211
482
  DEFAULT_DEVELOPER_SIGNUP_URL,
212
483
  });
484
+ function resolveTrustedApiBaseUrl(value) {
485
+ const result = careCompassApiBaseUrlSchema.safeParse(value);
486
+ if (result.success)
487
+ return result.data;
488
+ throw new Error(result.error.issues.map((issue) => issue.message).join("\n"));
489
+ }
213
490
  function rejectLegacyBaaInitFlags(flags, io) {
214
491
  const legacyFlag = LEGACY_BAA_INIT_FLAGS.find((flag) => flag in flags);
215
492
  if (!legacyFlag)
@@ -267,7 +544,7 @@ async function validateInitApiKey(apiBaseUrl, apiKey, accountType) {
267
544
  }
268
545
  catch (error) {
269
546
  const message = error instanceof Error ? error.message : "Network request failed";
270
- throw new Error(`Could not reach CareCompass authentication API at ${url}. ${message}`);
547
+ throw new CliTelemetryError(`Could not reach CareCompass authentication API at ${url}. ${message}`, classifyCliFailure(new Error(`Could not reach CareCompass authentication API. ${message}`)));
271
548
  }
272
549
  const text = await response.text();
273
550
  let payload = {};
@@ -278,27 +555,33 @@ async function validateInitApiKey(apiBaseUrl, apiKey, accountType) {
278
555
  payload = {};
279
556
  }
280
557
  if (!response.ok || payload.ok === false) {
558
+ const message = payload.error ?? "Invalid API key.";
559
+ const failure = classifyAuthFailure(response.status, message);
281
560
  return {
282
561
  ok: false,
283
- message: payload.error ?? "Invalid API key.",
562
+ message,
563
+ ...failure,
284
564
  };
285
565
  }
286
566
  const validatedType = typeof payload.accountType === "string"
287
567
  ? normalizeAccountType(payload.accountType)
288
568
  : null;
289
569
  if (!validatedType) {
570
+ const message = "Credential validation succeeded but did not return a recognized account type. Contact support@nexalink.care.";
290
571
  return {
291
572
  ok: false,
292
- message: "Credential validation succeeded but did not return a recognized account type. Contact support@nexalink.care.",
573
+ message,
574
+ ...classifyAuthFailure(401, message),
293
575
  };
294
576
  }
295
577
  return {
296
578
  ok: true,
297
579
  accountType: validatedType,
580
+ baaStatus: normalizeBaaStatus(payload.baaStatus),
298
581
  setup: normalizeAuthSetup(payload.setup),
299
582
  };
300
583
  }
301
- async function resolveInitApiKey(flags, io, apiBaseUrl) {
584
+ async function resolveInitApiKey(flags, io, apiBaseUrl, onAuthChecked = async () => undefined) {
302
585
  const credentialsPath = resolveCredentialsPath(io.env);
303
586
  let credentials = resolveApiKey(flagString(flags, "api-key"), io.env, credentialsPath);
304
587
  const urls = signupUrls(flags, io);
@@ -316,6 +599,7 @@ async function resolveInitApiKey(flags, io, apiBaseUrl) {
316
599
  const apiKey = useResolvedCredential
317
600
  ? credentials.apiKey
318
601
  : await promptForSecret(io, "Enter API key: ");
602
+ const authTelemetry = authTelemetryForSource(credentials.source);
319
603
  useResolvedCredential = false;
320
604
  credentials = { apiKey: null, source: "missing" };
321
605
  const normalizedApiKey = normalizeApiKey(apiKey);
@@ -324,9 +608,32 @@ async function resolveInitApiKey(flags, io, apiBaseUrl) {
324
608
  continue;
325
609
  }
326
610
  io.stdout.write("Validating credentials...\n");
327
- const validation = await validateInitApiKey(apiBaseUrl, normalizedApiKey, accountType);
611
+ let validation;
612
+ try {
613
+ validation = await validateInitApiKey(apiBaseUrl, normalizedApiKey, accountType);
614
+ }
615
+ catch (error) {
616
+ const failure = classifyCliFailure(error);
617
+ await onAuthChecked({
618
+ authResult: "failure",
619
+ baaStatus: "signed",
620
+ keySource: authTelemetry.keySource,
621
+ ...failure,
622
+ });
623
+ throw error;
624
+ }
328
625
  if (!validation.ok) {
626
+ await onAuthChecked({
627
+ authResult: "failure",
628
+ baaStatus: validation.baaStatus,
629
+ keySource: authTelemetry.keySource,
630
+ errorCode: validation.errorCode,
631
+ failureCategory: validation.failureCategory,
632
+ });
329
633
  io.stdout.write(`${validation.message}\n`);
634
+ const rateLimitError = recordUnauthenticatedAuthFailure(io);
635
+ if (rateLimitError)
636
+ throw rateLimitError;
330
637
  continue;
331
638
  }
332
639
  if (validation.accountType !== accountType) {
@@ -338,9 +645,16 @@ async function resolveInitApiKey(flags, io, apiBaseUrl) {
338
645
  continue;
339
646
  }
340
647
  writeStoredApiKey(normalizedApiKey, credentialsPath);
648
+ resetUnauthenticatedAuthFailures(io);
341
649
  io.stdout.write(`Stored CareCompass credentials at ${credentialsPath}\n`);
342
650
  io.stdout.write("Sign-in successful.\n");
343
- return { apiKey: normalizedApiKey, setup: validation.setup };
651
+ return {
652
+ apiKey: normalizedApiKey,
653
+ authMethod: authTelemetry.authMethod,
654
+ baaStatus: validation.baaStatus,
655
+ keySource: authTelemetry.keySource,
656
+ setup: validation.setup,
657
+ };
344
658
  }
345
659
  }
346
660
  function resolvePromptFile(config, configPath) {
@@ -349,8 +663,23 @@ function resolvePromptFile(config, configPath) {
349
663
  return config;
350
664
  // Prompt files are resolved relative to the config file so generated kits work
351
665
  // after the caller changes directories.
352
- const resolvedPath = resolve(dirname(configPath), promptFile);
353
- const systemInstruction = readFileSync(resolvedPath, "utf8").trim();
666
+ if (isAbsolute(promptFile)) {
667
+ throw new Error("Prompt file must stay inside the config directory.");
668
+ }
669
+ const configDir = realpathSync(resolve(dirname(configPath)));
670
+ const resolvedPath = resolve(configDir, promptFile);
671
+ let realPromptPath;
672
+ try {
673
+ realPromptPath = realpathSync(resolvedPath);
674
+ }
675
+ catch {
676
+ throw new Error(`CareCompass prompt file was not found: ${promptFile}`);
677
+ }
678
+ const relativePromptPath = relative(configDir, realPromptPath);
679
+ if (relativePromptPath.startsWith("..") || isAbsolute(relativePromptPath)) {
680
+ throw new Error("Prompt file must stay inside the config directory.");
681
+ }
682
+ const systemInstruction = readFileSync(realPromptPath, "utf8").trim();
354
683
  const validation = validateCareCompassConfig({
355
684
  ...config,
356
685
  prompt: {
@@ -562,16 +891,104 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
562
891
  // Start with anonymous CLI telemetry, then rebuild it with payer context after
563
892
  // a config has been generated or read.
564
893
  let cliTelemetry = createCareCompassCliTelemetry(cliSessionId);
565
- let activeCommand = "unknown";
894
+ const sessionStartedAt = Date.now();
895
+ const rawCommand = argv[0] ?? "help";
896
+ const telemetryCommandName = sanitizeCommandName(rawCommand);
897
+ let commandStartedAt = sessionStartedAt;
898
+ let commandStartedCaptured = false;
899
+ let commandsAttempted = 0;
900
+ let commandsSucceeded = 0;
901
+ let commandsFailed = 0;
902
+ let activeCommand = rawCommand;
903
+ let authCheckedCaptured = false;
904
+ let initStartedCaptured = false;
905
+ let sessionEndedCaptured = false;
906
+ const captureAuthChecked = async (telemetry) => {
907
+ if (authCheckedCaptured)
908
+ return;
909
+ authCheckedCaptured = true;
910
+ await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_AUTH_CHECKED, {
911
+ authResult: telemetry.authResult,
912
+ failureCategory: telemetry.authResult === "success"
913
+ ? null
914
+ : (telemetry.failureCategory ?? "unknown"),
915
+ ...(telemetry.errorCode ? { errorCode: telemetry.errorCode } : {}),
916
+ baaStatus: telemetry.baaStatus,
917
+ keySource: telemetry.keySource,
918
+ });
919
+ };
920
+ const captureCommandStarted = async () => {
921
+ if (commandStartedCaptured)
922
+ return;
923
+ commandStartedCaptured = true;
924
+ commandStartedAt = Date.now();
925
+ commandsAttempted += 1;
926
+ await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_COMMAND_STARTED, {
927
+ commandName: telemetryCommandName,
928
+ commandArgsCount: Math.max(argv.length - 1, 0),
929
+ });
930
+ };
931
+ const captureCommandCompleted = async () => {
932
+ await captureCommandStarted();
933
+ commandsSucceeded += 1;
934
+ await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_COMMAND_COMPLETED, {
935
+ commandName: telemetryCommandName,
936
+ commandDurationMs: Date.now() - commandStartedAt,
937
+ tokensUsed: null,
938
+ modelId: null,
939
+ });
940
+ };
941
+ const authenticateConfigCommand = async (flags) => {
942
+ let credentials;
943
+ try {
944
+ credentials = requireApiKey(flags, io);
945
+ }
946
+ catch (error) {
947
+ const failure = classifyCliFailure(error);
948
+ await captureAuthChecked({
949
+ authResult: "failure",
950
+ baaStatus: "signed",
951
+ keySource: "cli_flag",
952
+ errorCode: failure.errorCode,
953
+ failureCategory: failure.failureCategory,
954
+ });
955
+ const rateLimitError = recordUnauthenticatedAuthFailure(io);
956
+ throw rateLimitError ?? error;
957
+ }
958
+ const config = readConfig(flagString(flags, "config") ?? DEFAULT_CONFIG_PATH);
959
+ resetUnauthenticatedAuthFailures(io);
960
+ cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
961
+ await captureAuthChecked({
962
+ authResult: "success",
963
+ baaStatus: "not_required",
964
+ keySource: authTelemetryForSource(credentials.source).keySource,
965
+ failureCategory: null,
966
+ });
967
+ return { config, credentials };
968
+ };
969
+ const captureSessionEnded = async (exitReason) => {
970
+ if (sessionEndedCaptured)
971
+ return;
972
+ sessionEndedCaptured = true;
973
+ await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_SESSION_ENDED, {
974
+ sessionDurationMs: Date.now() - sessionStartedAt,
975
+ commandsAttempted,
976
+ commandsSucceeded,
977
+ commandsFailed,
978
+ exitReason,
979
+ });
980
+ };
566
981
  await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_STARTED);
567
982
  try {
568
983
  const { command, flags, positionals } = parseArgs(argv);
569
984
  activeCommand = command;
570
985
  if (command === "help" || command === "--help" || command === "-h") {
571
986
  printHelp(io);
987
+ await captureCommandCompleted();
572
988
  return 0;
573
989
  }
574
990
  if (command === "login") {
991
+ await captureCommandStarted();
575
992
  await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_LOGIN_STARTED);
576
993
  const apiKey = flagString(flags, "api-key") ?? positionals[0];
577
994
  if (!apiKey) {
@@ -581,20 +998,33 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
581
998
  errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.AUTHENTICATION_FAILED,
582
999
  failureCategory: "authentication",
583
1000
  });
1001
+ commandsFailed += 1;
584
1002
  return 1;
585
1003
  }
586
1004
  const credentialsPath = resolveCredentialsPath(io.env);
587
1005
  writeStoredApiKey(apiKey, credentialsPath);
588
1006
  io.stdout.write(`Stored CareCompass credentials at ${credentialsPath}\n`);
589
1007
  await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_LOGIN_SUCCEEDED);
1008
+ await captureCommandCompleted();
590
1009
  return 0;
591
1010
  }
592
1011
  if (command === "init") {
593
- await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_INIT_STARTED);
594
1012
  rejectLegacyBaaInitFlags(flags, io);
595
1013
  const outputDir = flagString(flags, "output-dir") ?? DEFAULT_OUTPUT_DIR;
596
- const apiBaseUrl = flagString(flags, "api-base-url") ?? DEFAULT_API_BASE_URL;
597
- const initAuth = await resolveInitApiKey(flags, io, apiBaseUrl);
1014
+ const apiBaseUrl = resolveTrustedApiBaseUrl(flagString(flags, "api-base-url") ?? DEFAULT_API_BASE_URL);
1015
+ const initAuth = await resolveInitApiKey(flags, io, apiBaseUrl, captureAuthChecked);
1016
+ const config = buildInitConfig(flags, initAuth.setup);
1017
+ cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
1018
+ await captureAuthChecked({
1019
+ authResult: "success",
1020
+ baaStatus: initAuth.baaStatus,
1021
+ keySource: initAuth.keySource,
1022
+ failureCategory: null,
1023
+ });
1024
+ await captureCommandStarted();
1025
+ const initStartedAt = Date.now();
1026
+ await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_INIT_STARTED);
1027
+ initStartedCaptured = true;
598
1028
  const output = flagString(flags, "output") ??
599
1029
  join(outputDir, "carecompass.config.json");
600
1030
  const snippetOutput = flagString(flags, "snippet-output") ??
@@ -617,8 +1047,6 @@ export async function runCareCompassCli(argv = process.argv.slice(2), io = {
617
1047
  }
618
1048
  }
619
1049
  mkdirSync(outputDir, { recursive: true });
620
- const config = buildInitConfig(flags, initAuth.setup);
621
- cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
622
1050
  writeFileSync(output, `${JSON.stringify(config, null, 2)}\n`);
623
1051
  writeFileSync(promptOutput, `${DEFAULT_SYSTEM_PROMPT}\n`);
624
1052
  const resolvedConfig = readConfig(output);
@@ -657,33 +1085,41 @@ Step 5: Follow the generated setup guide
657
1085
  Next command:
658
1086
  npx @nexalink/carecompass-sdk validate --config ${output}
659
1087
  `);
1088
+ await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_INIT_COMPLETED, {
1089
+ initDurationMs: Date.now() - initStartedAt,
1090
+ authMethod: initAuth.authMethod,
1091
+ baaStatus: initAuth.baaStatus,
1092
+ });
660
1093
  await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_SETUP_KIT_GENERATED);
1094
+ await captureCommandCompleted();
661
1095
  return 0;
662
1096
  }
663
1097
  if (command === "validate") {
664
1098
  try {
665
- const credentials = requireApiKey(flags, io);
666
- const config = readConfig(flagString(flags, "config") ?? DEFAULT_CONFIG_PATH);
667
- cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
1099
+ const { config, credentials } = await authenticateConfigCommand(flags);
1100
+ await captureCommandStarted();
668
1101
  if (flags["local-only"] !== true) {
669
1102
  await assertHostedConfig(config, credentials.apiKey);
670
1103
  }
671
1104
  io.stdout.write(`CareCompass config is valid for ${config.payerId}. Credentials loaded from ${credentials.source}.\n`);
672
1105
  await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_VALIDATION_SUCCEEDED);
1106
+ await captureCommandCompleted();
673
1107
  return 0;
674
1108
  }
675
1109
  catch (error) {
676
- await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_VALIDATION_FAILED, {
677
- errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CLI_COMMAND_FAILED,
678
- failureCategory: "configuration",
679
- });
1110
+ const failure = classifyCliFailure(error);
1111
+ if (commandStartedCaptured) {
1112
+ await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_VALIDATION_FAILED, {
1113
+ errorCode: failure.errorCode,
1114
+ failureCategory: failure.failureCategory,
1115
+ });
1116
+ }
680
1117
  throw error;
681
1118
  }
682
1119
  }
683
1120
  if (command === "snippet") {
684
- requireApiKey(flags, io);
685
- const config = readConfig(flagString(flags, "config") ?? DEFAULT_CONFIG_PATH);
686
- cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
1121
+ const { config } = await authenticateConfigCommand(flags);
1122
+ await captureCommandStarted();
687
1123
  const snippet = generateCareCompassSnippet(runtimeConfig(config), {
688
1124
  mode: snippetMode(flags),
689
1125
  });
@@ -697,12 +1133,12 @@ Next command:
697
1133
  io.stdout.write(`${snippet}\n`);
698
1134
  }
699
1135
  await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_SNIPPET_GENERATED);
1136
+ await captureCommandCompleted();
700
1137
  return 0;
701
1138
  }
702
1139
  if (command === "doctor") {
703
- const credentials = requireApiKey(flags, io);
704
- const config = readConfig(flagString(flags, "config") ?? DEFAULT_CONFIG_PATH);
705
- cliTelemetry = createCareCompassCliTelemetry(cliSessionId, config);
1140
+ const { config, credentials } = await authenticateConfigCommand(flags);
1141
+ await captureCommandStarted();
706
1142
  let hostedStatus = "not checked";
707
1143
  try {
708
1144
  await assertHostedConfig(config, credentials.apiKey);
@@ -723,24 +1159,35 @@ Hosted API: ${hostedStatus}
723
1159
  Status: ready for staging verification
724
1160
  `);
725
1161
  await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_DOCTOR_COMPLETED);
1162
+ await captureCommandCompleted();
726
1163
  return 0;
727
1164
  }
1165
+ await captureCommandStarted();
728
1166
  throw new Error(`Unknown command: ${command}`);
729
1167
  }
730
1168
  catch (error) {
731
- if (activeCommand === "init") {
1169
+ commandsFailed += 1;
1170
+ if (!commandStartedCaptured && commandsAttempted === 0) {
1171
+ commandsAttempted = 1;
1172
+ }
1173
+ const failure = classifyCliFailure(error);
1174
+ if (activeCommand === "init" && initStartedCaptured) {
732
1175
  await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_INIT_FAILED, {
733
- errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CLI_COMMAND_FAILED,
734
- failureCategory: "unknown",
1176
+ errorCode: failure.errorCode,
1177
+ failureCategory: failure.failureCategory,
735
1178
  });
736
1179
  }
737
1180
  await cliTelemetry.captureAsync(CARECOMPASS_TELEMETRY_EVENTS.CLI_COMMAND_FAILED, {
738
- errorCode: CARECOMPASS_TELEMETRY_ERROR_CODES.CLI_COMMAND_FAILED,
739
- failureCategory: "unknown",
1181
+ errorCode: failure.errorCode,
1182
+ failureCategory: failure.failureCategory,
1183
+ commandName: telemetryCommandName,
740
1184
  });
741
1185
  io.stderr.write(`${error.message}\n`);
742
1186
  return 1;
743
1187
  }
1188
+ finally {
1189
+ await captureSessionEnded(commandsFailed > 0 ? "error" : "user_exit");
1190
+ }
744
1191
  }
745
1192
  const isMainModule = () => Boolean(process.argv[1]) &&
746
1193
  realpathSync(fileURLToPath(import.meta.url)) ===