@lexq/cli 0.1.32 → 0.1.34

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/index.js CHANGED
@@ -59,7 +59,7 @@ var ApiError = class extends Error {
59
59
  statusCode;
60
60
  errorCode;
61
61
  };
62
- async function apiRequest(method, path, options = {}) {
62
+ async function doFetch(method, path, options) {
63
63
  const config = loadConfig();
64
64
  const baseUrl = options.baseUrl ?? config.baseUrl;
65
65
  const apiKey = options.apiKey ?? config.apiKey;
@@ -69,18 +69,11 @@ async function apiRequest(method, path, options = {}) {
69
69
  const url = new URL(path, baseUrl.endsWith("/") ? baseUrl : baseUrl + "/");
70
70
  if (options.params) {
71
71
  for (const [key, value] of Object.entries(options.params)) {
72
- if (value !== void 0 && value !== "") {
73
- url.searchParams.set(key, value);
74
- }
72
+ if (value !== void 0 && value !== "") url.searchParams.set(key, value);
75
73
  }
76
74
  }
77
- const headers = {
78
- "X-API-KEY": apiKey,
79
- Accept: "application/json"
80
- };
81
- if (options.body) {
82
- headers["Content-Type"] = "application/json";
83
- }
75
+ const headers = { "X-API-KEY": apiKey, Accept: "application/json" };
76
+ if (options.body) headers["Content-Type"] = "application/json";
84
77
  if (options.dryRun) {
85
78
  const masked = apiKey.length > 8 ? apiKey.substring(0, 4) + "****" + apiKey.substring(apiKey.length - 4) : "****";
86
79
  console.log(`${method} ${url.toString()}`);
@@ -94,9 +87,7 @@ async function apiRequest(method, path, options = {}) {
94
87
  console.log("\n(Use without --dry-run to execute)");
95
88
  process.exit(0);
96
89
  }
97
- if (options.verbose) {
98
- console.error(`\u2192 ${method} ${url.toString()}`);
99
- }
90
+ if (options.verbose) console.error(`\u2192 ${method} ${url.toString()}`);
100
91
  const startTime = Date.now();
101
92
  const response = await fetch(url.toString(), {
102
93
  method,
@@ -106,14 +97,9 @@ async function apiRequest(method, path, options = {}) {
106
97
  if (options.verbose) {
107
98
  console.error(`\u2190 ${response.status} ${response.statusText} (${Date.now() - startTime}ms)`);
108
99
  }
109
- const contentType = response.headers.get("content-type") ?? "";
110
- if (contentType.includes("text/csv") || contentType.includes("application/octet-stream")) {
111
- return response;
112
- }
113
- if (response.status === 204 || contentType === "") {
114
- return void 0;
115
- }
116
- const json = await response.json();
100
+ return response;
101
+ }
102
+ function assertOk(response, json) {
117
103
  if (!response.ok || json.result !== "SUCCESS") {
118
104
  throw new ApiError(
119
105
  response.status,
@@ -121,7 +107,23 @@ async function apiRequest(method, path, options = {}) {
121
107
  json.message ?? `Request failed with status ${response.status}`
122
108
  );
123
109
  }
124
- return json.data;
110
+ }
111
+ async function apiRequest(method, path, options = {}) {
112
+ const { data } = await apiRequestWithMeta(method, path, options);
113
+ return data;
114
+ }
115
+ async function apiRequestWithMeta(method, path, options = {}) {
116
+ const response = await doFetch(method, path, options);
117
+ const contentType = response.headers.get("content-type") ?? "";
118
+ if (contentType.includes("text/csv") || contentType.includes("application/octet-stream")) {
119
+ return { data: response, meta: null };
120
+ }
121
+ if (response.status === 204 || contentType === "") {
122
+ return { data: void 0, meta: null };
123
+ }
124
+ const json = await response.json();
125
+ assertOk(response, json);
126
+ return { data: json.data, meta: json.meta ?? null };
125
127
  }
126
128
 
127
129
  // src/lib/output.ts
@@ -163,6 +165,23 @@ function printError(error) {
163
165
  console.error(JSON.stringify({ error: "UNKNOWN", message: String(error) }, null, 2));
164
166
  }
165
167
  }
168
+ function printUnregisteredFactsWarning(facts) {
169
+ if (facts.length === 0) return;
170
+ const s = facts.length === 1 ? "" : "s";
171
+ console.error(
172
+ `
173
+ \u26A0 ${facts.length} undefined fact${s} referenced \u2014 register to enable validation:`
174
+ );
175
+ for (const f of facts) {
176
+ const type = f.inferredType ?? f.candidateTypes?.[0] ?? "STRING";
177
+ const note = f.conflict ? ` (ambiguous: ${f.candidateTypes?.join(" | ") ?? "?"})` : "";
178
+ console.error(` \u2022 ${f.key} \u2192 ${type}${note}`);
179
+ console.error(
180
+ ` lexq facts create --key ${f.key} --name "${f.suggestedName}" --type ${type}`
181
+ );
182
+ }
183
+ console.error("");
184
+ }
166
185
 
167
186
  // src/commands/auth.ts
168
187
  function registerAuthCommands(program) {
@@ -935,7 +954,7 @@ ${data.length} total`);
935
954
  try {
936
955
  const globalOpts = program.opts();
937
956
  const body = JSON.parse(opts.json);
938
- const data = await apiRequest(
957
+ const { data, meta } = await apiRequestWithMeta(
939
958
  "POST",
940
959
  `policy-groups/${opts.groupId}/versions/${opts.versionId}/rules`,
941
960
  {
@@ -947,6 +966,7 @@ ${data.length} total`);
947
966
  }
948
967
  );
949
968
  printJson(data);
969
+ printUnregisteredFactsWarning(meta?.unregisteredFacts ?? []);
950
970
  } catch (error) {
951
971
  printError(error);
952
972
  process.exit(1);
@@ -970,7 +990,7 @@ ${data.length} total`);
970
990
  try {
971
991
  const globalOpts = program.opts();
972
992
  const body = JSON.parse(opts.json);
973
- const data = await apiRequest(
993
+ const { data, meta } = await apiRequestWithMeta(
974
994
  "PUT",
975
995
  `policy-groups/${opts.groupId}/versions/${opts.versionId}/rules/${opts.id}`,
976
996
  {
@@ -982,6 +1002,7 @@ ${data.length} total`);
982
1002
  }
983
1003
  );
984
1004
  printJson(data);
1005
+ printUnregisteredFactsWarning(meta?.unregisteredFacts ?? []);
985
1006
  } catch (error) {
986
1007
  printError(error);
987
1008
  process.exit(1);
@@ -1099,6 +1120,7 @@ function registerFactCommands(program) {
1099
1120
 
1100
1121
  Commands:
1101
1122
  list List all fact definitions
1123
+ unregistered List facts referenced by a version but not yet defined
1102
1124
  create Register a new fact
1103
1125
  update Update fact metadata
1104
1126
  delete Remove a fact definition
@@ -1143,6 +1165,52 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1143
1165
  process.exit(1);
1144
1166
  }
1145
1167
  });
1168
+ facts.command("unregistered").description("List facts referenced by a version but not yet defined").requiredOption("--group-id <groupId>", "Policy group ID").requiredOption("--version-id <versionId>", "Policy version ID").addHelpText(
1169
+ "after",
1170
+ dedent6`
1171
+
1172
+ Facts used in a version's rules that have no definition yet (read-only).
1173
+ Does not block publish/deploy — register them to enable validation.
1174
+
1175
+ Example:
1176
+ $ lexq facts unregistered --group-id <gid> --version-id <vid> --format table
1177
+ `
1178
+ ).action(async (opts) => {
1179
+ try {
1180
+ const globalOpts = program.opts();
1181
+ const format = globalOpts.format ?? "json";
1182
+ const data = await apiRequest(
1183
+ "GET",
1184
+ `policy-groups/${opts.groupId}/versions/${opts.versionId}/unregistered-facts`,
1185
+ {
1186
+ apiKey: globalOpts.apiKey,
1187
+ baseUrl: globalOpts.baseUrl,
1188
+ dryRun: globalOpts.dryRun,
1189
+ verbose: globalOpts.verbose
1190
+ }
1191
+ );
1192
+ if (format === "table") {
1193
+ printTable(
1194
+ ["Key", "Type", "Suggested Name", "Conflict", "Sources"],
1195
+ data.map((f) => [
1196
+ f.key,
1197
+ f.inferredType ?? f.candidateTypes?.join("|") ?? "?",
1198
+ f.suggestedName,
1199
+ f.conflict ? "\u2713" : "\u2013",
1200
+ f.sources.map((s) => `${s.kind}:${s.field}`).join(", ")
1201
+ ]),
1202
+ { truncate: 28 }
1203
+ );
1204
+ console.log(`
1205
+ ${data.length} unregistered`);
1206
+ } else {
1207
+ printJson(data);
1208
+ }
1209
+ } catch (error) {
1210
+ printError(error);
1211
+ process.exit(1);
1212
+ }
1213
+ });
1146
1214
  facts.command("create").description("Create a new fact definition").option("--key <key>", "Fact key (lowercase, underscores)").option("--name <n>", "Display name").option("--type <type>", "Value type: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER").option("--description <desc>", "Description").option("--required", "Mark as required", false).option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
1147
1215
  "after",
1148
1216
  dedent6`
@@ -1316,6 +1384,7 @@ function registerDeployCommands(program) {
1316
1384
  ).action(async (opts) => {
1317
1385
  try {
1318
1386
  const globalOpts = program.opts();
1387
+ await warnUnregisteredFacts(globalOpts, opts.groupId, opts.versionId);
1319
1388
  await apiRequest(
1320
1389
  "POST",
1321
1390
  `policy-groups/${opts.groupId}/versions/${opts.versionId}/publish`,
@@ -1345,6 +1414,7 @@ function registerDeployCommands(program) {
1345
1414
  ).action(async (opts) => {
1346
1415
  try {
1347
1416
  const globalOpts = program.opts();
1417
+ await warnUnregisteredFacts(globalOpts, opts.groupId, opts.versionId);
1348
1418
  await apiRequest("POST", `policy-groups/${opts.groupId}/deploy`, {
1349
1419
  apiKey: globalOpts.apiKey,
1350
1420
  baseUrl: globalOpts.baseUrl,
@@ -1588,6 +1658,18 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1588
1658
  }
1589
1659
  });
1590
1660
  }
1661
+ async function warnUnregisteredFacts(globalOpts, groupId, versionId) {
1662
+ if (globalOpts.dryRun) return;
1663
+ try {
1664
+ const facts = await apiRequest(
1665
+ "GET",
1666
+ `policy-groups/${groupId}/versions/${versionId}/unregistered-facts`,
1667
+ { apiKey: globalOpts.apiKey, baseUrl: globalOpts.baseUrl, verbose: globalOpts.verbose }
1668
+ );
1669
+ printUnregisteredFactsWarning(facts);
1670
+ } catch {
1671
+ }
1672
+ }
1591
1673
 
1592
1674
  // src/commands/analytics.ts
1593
1675
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
@@ -2858,14 +2940,17 @@ function createCallApiFromConfig() {
2858
2940
  apiKey: config.apiKey,
2859
2941
  baseUrl: config.baseUrl
2860
2942
  };
2861
- const data = await apiRequest(method, path, {
2943
+ const { data, meta } = await apiRequestWithMeta(method, path, {
2862
2944
  ...clientOpts,
2863
2945
  body: opts?.body,
2864
2946
  params: opts?.params
2865
2947
  });
2866
- return {
2867
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
2868
- };
2948
+ const content = [
2949
+ { type: "text", text: JSON.stringify(data, null, 2) }
2950
+ ];
2951
+ const warning = formatUnregisteredFactWarning(meta);
2952
+ if (warning) content.push({ type: "text", text: warning });
2953
+ return { content };
2869
2954
  } catch (error) {
2870
2955
  const message = error instanceof Error ? error.message : String(error);
2871
2956
  return {
@@ -2881,6 +2966,22 @@ function paginationParams(page, size) {
2881
2966
  if (size !== void 0) params.size = String(size);
2882
2967
  return params;
2883
2968
  }
2969
+ function formatUnregisteredFactWarning(meta) {
2970
+ const facts = meta?.unregisteredFacts ?? [];
2971
+ if (facts.length === 0) return null;
2972
+ const lines = facts.map((f) => {
2973
+ if (f.inferredType) {
2974
+ return ` \u2022 ${f.key} (${f.inferredType}) \u2014 register: lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: "${f.inferredType}" })`;
2975
+ }
2976
+ const choices = f.candidateTypes?.join(" | ") ?? "STRING | NUMBER | BOOLEAN";
2977
+ return ` \u2022 ${f.key} (ambiguous) \u2014 pick a type (${choices}), then lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: <chosen> })`;
2978
+ });
2979
+ return [
2980
+ `\u26A0 The saved rule references ${facts.length} fact(s) not defined in the schema. The rule was saved \u2014 this does not block \u2014 but undefined facts skip type validation and the dry-run requirements analyzer.`,
2981
+ ...lines,
2982
+ `Register them now so the rule validates as intended, or call lexq_facts_unregistered to review the version's full list.`
2983
+ ].join("\n");
2984
+ }
2884
2985
 
2885
2986
  // src/mcp/tools/status.ts
2886
2987
  function registerStatusTools(server, callApi) {
@@ -3157,6 +3258,9 @@ function registerRuleTools(server, callApi) {
3157
3258
  If a required key is missing, ask the user to confirm the type, isRequired, and description
3158
3259
  before calling lexq_facts_create — registering facts enables type validation, Console UI
3159
3260
  autocomplete, and the dry-run requirements analyzer.
3261
+
3262
+ After saving, lexq_facts_unregistered lists any keys this version references but has not
3263
+ defined (non-blocking, version-wide) — use it to decide what to register.
3160
3264
 
3161
3265
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
3162
3266
  Operators: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
@@ -3342,6 +3446,18 @@ function registerFactTools(server, callApi) {
3342
3446
  },
3343
3447
  async () => callApi("GET", "schema/action-metadata")
3344
3448
  );
3449
+ server.registerTool(
3450
+ "lexq_facts_unregistered",
3451
+ {
3452
+ title: "List Unregistered Facts",
3453
+ description: "List facts referenced by a version's rules but not yet defined (read-only \u2014 does not block publish/deploy, INV-4). Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Register them with lexq_facts_create to enable type validation and the dry-run requirements analyzer.",
3454
+ inputSchema: {
3455
+ groupId: z4.string().uuid().describe("Policy group ID"),
3456
+ versionId: z4.string().uuid().describe("Version ID")
3457
+ }
3458
+ },
3459
+ async ({ groupId, versionId }) => callApi("GET", `policy-groups/${groupId}/versions/${versionId}/unregistered-facts`)
3460
+ );
3345
3461
  }
3346
3462
 
3347
3463
  // src/mcp/tools/deploy.ts
@@ -3351,7 +3467,7 @@ function registerDeployTools(server, callApi) {
3351
3467
  "lexq_deploy_publish",
3352
3468
  {
3353
3469
  title: "Publish Version",
3354
- description: "Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule.",
3470
+ description: "Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts referenced by rules do not block publishing (INV-4); call lexq_facts_unregistered first to review them.",
3355
3471
  inputSchema: {
3356
3472
  groupId: z5.string().uuid().describe("Policy group ID"),
3357
3473
  versionId: z5.string().uuid().describe("Version ID to publish"),
@@ -3364,7 +3480,7 @@ function registerDeployTools(server, callApi) {
3364
3480
  "lexq_deploy_live",
3365
3481
  {
3366
3482
  title: "Deploy to Live",
3367
- description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately.",
3483
+ description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Undefined facts do not block deployment (INV-4); use lexq_facts_unregistered to review what the version references but has not defined.",
3368
3484
  inputSchema: {
3369
3485
  groupId: z5.string().uuid().describe("Policy group ID"),
3370
3486
  versionId: z5.string().uuid().describe("Version ID to deploy"),
@@ -25,13 +25,22 @@ type CallApi = (method: string, path: string, opts?: {
25
25
  };
26
26
  }) => Promise<McpToolResult>;
27
27
  declare function paginationParams(page?: number, size?: number): Record<string, string>;
28
+ /**
29
+ * Builds a salient, actionable warning from a response's meta when it reports facts that
30
+ * rules reference but the schema does not define. Returns null when there is nothing to warn.
31
+ *
32
+ * Shared by both CallApi implementations (CLI stdio here, lexq-mcp HTTP via @lexq/cli/mcp)
33
+ * so the agent-facing text is identical on every surface. The backend only populates
34
+ * meta.unregisteredFacts on rule create/update, so this only ever fires there.
35
+ */
36
+ declare function formatUnregisteredFactWarning(meta: unknown): string | null;
28
37
 
29
38
  /**
30
- * Registers all 66 MCP tools on the given server.
39
+ * Registers all MCP tools on the given server.
31
40
  *
32
41
  * @param server - McpServer instance
33
42
  * @param callApi - API caller function (config-based for CLI, Bearer-based for HTTP)
34
43
  */
35
44
  declare function registerAllTools(server: McpServer, callApi: CallApi): void;
36
45
 
37
- export { type CallApi, type McpToolResult, paginationParams, registerAllTools };
46
+ export { type CallApi, type McpToolResult, formatUnregisteredFactWarning, paginationParams, registerAllTools };
@@ -161,6 +161,22 @@ function paginationParams(page, size) {
161
161
  if (size !== void 0) params.size = String(size);
162
162
  return params;
163
163
  }
164
+ function formatUnregisteredFactWarning(meta) {
165
+ const facts = meta?.unregisteredFacts ?? [];
166
+ if (facts.length === 0) return null;
167
+ const lines = facts.map((f) => {
168
+ if (f.inferredType) {
169
+ return ` \u2022 ${f.key} (${f.inferredType}) \u2014 register: lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: "${f.inferredType}" })`;
170
+ }
171
+ const choices = f.candidateTypes?.join(" | ") ?? "STRING | NUMBER | BOOLEAN";
172
+ return ` \u2022 ${f.key} (ambiguous) \u2014 pick a type (${choices}), then lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: <chosen> })`;
173
+ });
174
+ return [
175
+ `\u26A0 The saved rule references ${facts.length} fact(s) not defined in the schema. The rule was saved \u2014 this does not block \u2014 but undefined facts skip type validation and the dry-run requirements analyzer.`,
176
+ ...lines,
177
+ `Register them now so the rule validates as intended, or call lexq_facts_unregistered to review the version's full list.`
178
+ ].join("\n");
179
+ }
164
180
 
165
181
  // src/mcp/tools/versions.ts
166
182
  function registerVersionTools(server, callApi) {
@@ -290,6 +306,9 @@ function registerRuleTools(server, callApi) {
290
306
  If a required key is missing, ask the user to confirm the type, isRequired, and description
291
307
  before calling lexq_facts_create — registering facts enables type validation, Console UI
292
308
  autocomplete, and the dry-run requirements analyzer.
309
+
310
+ After saving, lexq_facts_unregistered lists any keys this version references but has not
311
+ defined (non-blocking, version-wide) — use it to decide what to register.
293
312
 
294
313
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
295
314
  Operators: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
@@ -475,6 +494,18 @@ function registerFactTools(server, callApi) {
475
494
  },
476
495
  async () => callApi("GET", "schema/action-metadata")
477
496
  );
497
+ server.registerTool(
498
+ "lexq_facts_unregistered",
499
+ {
500
+ title: "List Unregistered Facts",
501
+ description: "List facts referenced by a version's rules but not yet defined (read-only \u2014 does not block publish/deploy, INV-4). Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Register them with lexq_facts_create to enable type validation and the dry-run requirements analyzer.",
502
+ inputSchema: {
503
+ groupId: z4.string().uuid().describe("Policy group ID"),
504
+ versionId: z4.string().uuid().describe("Version ID")
505
+ }
506
+ },
507
+ async ({ groupId, versionId }) => callApi("GET", `policy-groups/${groupId}/versions/${versionId}/unregistered-facts`)
508
+ );
478
509
  }
479
510
 
480
511
  // src/mcp/tools/deploy.ts
@@ -484,7 +515,7 @@ function registerDeployTools(server, callApi) {
484
515
  "lexq_deploy_publish",
485
516
  {
486
517
  title: "Publish Version",
487
- description: "Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule.",
518
+ description: "Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts referenced by rules do not block publishing (INV-4); call lexq_facts_unregistered first to review them.",
488
519
  inputSchema: {
489
520
  groupId: z5.string().uuid().describe("Policy group ID"),
490
521
  versionId: z5.string().uuid().describe("Version ID to publish"),
@@ -497,7 +528,7 @@ function registerDeployTools(server, callApi) {
497
528
  "lexq_deploy_live",
498
529
  {
499
530
  title: "Deploy to Live",
500
- description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately.",
531
+ description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Undefined facts do not block deployment (INV-4); use lexq_facts_unregistered to review what the version references but has not defined.",
501
532
  inputSchema: {
502
533
  groupId: z5.string().uuid().describe("Policy group ID"),
503
534
  versionId: z5.string().uuid().describe("Version ID to deploy"),
@@ -1175,6 +1206,7 @@ function registerAllTools(server, callApi) {
1175
1206
  registerWebhookSubscriptionTools(server, callApi);
1176
1207
  }
1177
1208
  export {
1209
+ formatUnregisteredFactWarning,
1178
1210
  paginationParams,
1179
1211
  registerAllTools
1180
1212
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexq/cli",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "LexQ CLI — manage policies, simulate rules, and deploy from the terminal. Built for humans and AI agents.",
5
5
  "type": "module",
6
6
  "bin": {