@lexq/cli 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +97 -5
- package/dist/mcp/register.js +19 -1
- package/package.json +3 -2
- package/skills/lexq-recipes/SKILL.md +5 -1
- package/skills/lexq-shared/SKILL.md +1 -0
- package/skills/lexq-simulation/SKILL.md +12 -6
package/README.md
CHANGED
|
@@ -277,7 +277,7 @@ Checks that guard a stated fact against the thing that owns it. All of them run
|
|
|
277
277
|
|
|
278
278
|
```bash
|
|
279
279
|
pnpm enums:check # Generated enums match the engine contract
|
|
280
|
-
pnpm
|
|
280
|
+
pnpm prose:check # Numbers and code tables in prose match the contract
|
|
281
281
|
pnpm ab-key:check # Tool descriptions still name the A/B traffic key
|
|
282
282
|
pnpm surface:check # Nothing internal reached a public file
|
|
283
283
|
pnpm registry:check # server.json agrees with package.json
|
package/dist/index.js
CHANGED
|
@@ -72,6 +72,7 @@ var survivesDoubleRoundTrip = (literal) => isSafeNumber(literal);
|
|
|
72
72
|
var parseNumber = (value) => survivesDoubleRoundTrip(value) ? parseFloat(value) : new LosslessNumber(value);
|
|
73
73
|
var parseJson = (text) => losslessParse(text, void 0, parseNumber);
|
|
74
74
|
var stringifyJson = (value, space) => losslessStringify(value, void 0, space);
|
|
75
|
+
var isLosslessNumber = (value) => value instanceof LosslessNumber;
|
|
75
76
|
|
|
76
77
|
// src/lib/api-client.ts
|
|
77
78
|
var ApiError = class extends Error {
|
|
@@ -1338,7 +1339,7 @@ ${data.length} unregistered`);
|
|
|
1338
1339
|
process.exit(1);
|
|
1339
1340
|
}
|
|
1340
1341
|
});
|
|
1341
|
-
facts.command("create").description("Create a new fact definition").option("--key <key>", "Fact key (letters, numbers, 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("--pii", "Mark as PII \u2014 value is masked on every read surface", false).option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
|
|
1342
|
+
facts.command("create").description("Create a new fact definition").option("--key <key>", "Fact key (letters, numbers, 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("--pii", "Mark as PII \u2014 value is masked on every read surface", false).option("--allowed-values <csv>", "Comma-separated list of accepted values").option("--min <number>", "Lower bound, inclusive (numeric types only)").option("--max <number>", "Upper bound, inclusive (numeric types only)").option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
|
|
1342
1343
|
"after",
|
|
1343
1344
|
dedent6`
|
|
1344
1345
|
|
|
@@ -1354,9 +1355,21 @@ ${data.length} unregistered`);
|
|
|
1354
1355
|
"isRequired": false
|
|
1355
1356
|
}'
|
|
1356
1357
|
|
|
1358
|
+
$ lexq facts create --key riskScore --name "Risk Score" --type NUMBER --min 0 --max 100
|
|
1359
|
+
$ lexq facts create --key tier --name "Tier" --type STRING \
|
|
1360
|
+
--allowed-values "GOLD, SILVER, BRONZE"
|
|
1361
|
+
|
|
1357
1362
|
Value Types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
|
|
1358
1363
|
Key Format: starts with a letter, then letters, numbers, and underscores.
|
|
1359
1364
|
Casing is yours to choose (e.g., paymentAmount, payment_amount)
|
|
1365
|
+
|
|
1366
|
+
Value Domain: --allowed-values, --min, and --max say which values the fact accepts.
|
|
1367
|
+
A rule can then only compare against those values, and the console turns
|
|
1368
|
+
the rule editor into a dropdown instead of a free text box.
|
|
1369
|
+
STRING types take --allowed-values. NUMBER types take all three.
|
|
1370
|
+
BOOLEAN takes none — it has only two values to begin with.
|
|
1371
|
+
For list types the constraint applies to each element.
|
|
1372
|
+
Leave them out for no constraint, which is how facts behaved before.
|
|
1360
1373
|
`
|
|
1361
1374
|
).action(async (opts) => {
|
|
1362
1375
|
try {
|
|
@@ -1375,14 +1388,25 @@ ${data.length} unregistered`);
|
|
|
1375
1388
|
process.exit(1);
|
|
1376
1389
|
}
|
|
1377
1390
|
});
|
|
1378
|
-
facts.command("update").description("Update a fact definition").requiredOption("--id <factId>", "Fact definition ID").option("--name <n>", "Display name").option("--description <desc>", "Description").option("--required", "Mark as required").option("--no-required", "Mark as not required").option("--pii", "Mark as PII (enables masking)").option("--no-pii", "Unmark as PII (disables masking \u2014 value becomes visible)").option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
|
|
1391
|
+
facts.command("update").description("Update a fact definition").requiredOption("--id <factId>", "Fact definition ID").option("--name <n>", "Display name").option("--description <desc>", "Description").option("--required", "Mark as required").option("--no-required", "Mark as not required").option("--pii", "Mark as PII (enables masking)").option("--no-pii", "Unmark as PII (disables masking \u2014 value becomes visible)").option("--type <type>", "Value type: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER").option("--allowed-values <csv>", "Comma-separated list of accepted values").option("--min <number>", "Lower bound, inclusive (numeric types only)").option("--max <number>", "Upper bound, inclusive (numeric types only)").option("--clear-value-domain", "Remove the value constraint entirely").option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
|
|
1379
1392
|
"after",
|
|
1380
1393
|
dedent6`
|
|
1381
1394
|
|
|
1382
|
-
System facts
|
|
1395
|
+
System facts accept name, description, and the PII flag only.
|
|
1383
1396
|
|
|
1384
|
-
|
|
1397
|
+
Examples:
|
|
1385
1398
|
$ lexq facts update --id <factId> --name "Updated Name" --required
|
|
1399
|
+
$ lexq facts update --id <factId> --min 0 --max 100
|
|
1400
|
+
$ lexq facts update --id <factId> --clear-value-domain
|
|
1401
|
+
|
|
1402
|
+
Only the flags you pass are changed. Leaving out --allowed-values, --min, and --max
|
|
1403
|
+
leaves the constraint alone; --clear-value-domain removes it.
|
|
1404
|
+
|
|
1405
|
+
Narrowing a constraint does not rewrite rules that already reference this fact. They keep
|
|
1406
|
+
working, and the next time one is saved it is judged against the new constraint.
|
|
1407
|
+
|
|
1408
|
+
Pass --type when the accepted values are numbers and you are not also naming the type,
|
|
1409
|
+
so "10" is sent as a number rather than a string. --json settles it either way.
|
|
1386
1410
|
`
|
|
1387
1411
|
).action(async (opts) => {
|
|
1388
1412
|
try {
|
|
@@ -1496,16 +1520,66 @@ function buildCreateBody2(opts) {
|
|
|
1496
1520
|
isPii: opts.pii === true
|
|
1497
1521
|
};
|
|
1498
1522
|
if (opts.description) body.description = opts.description;
|
|
1523
|
+
const domain = buildValueDomain(opts, opts.type);
|
|
1524
|
+
if (domain) body.valueDomain = domain;
|
|
1499
1525
|
return body;
|
|
1500
1526
|
}
|
|
1501
1527
|
function buildUpdateBody2(opts) {
|
|
1502
1528
|
const body = {};
|
|
1503
1529
|
if (opts.name) body.name = opts.name;
|
|
1504
1530
|
if (opts.description !== void 0) body.description = opts.description;
|
|
1531
|
+
if (opts.type) body.type = opts.type;
|
|
1505
1532
|
if (typeof opts.required === "boolean") body.isRequired = opts.required;
|
|
1506
1533
|
if (typeof opts.isPii === "boolean") body.isPii = opts.isPii;
|
|
1534
|
+
if (opts.clearValueDomain === true) {
|
|
1535
|
+
if (hasDomainFlag(opts)) {
|
|
1536
|
+
throw new Error(
|
|
1537
|
+
"--clear-value-domain cannot be combined with --allowed-values, --min, or --max."
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
body.valueDomain = {};
|
|
1541
|
+
return body;
|
|
1542
|
+
}
|
|
1543
|
+
const domain = buildValueDomain(opts, opts.type);
|
|
1544
|
+
if (domain) body.valueDomain = domain;
|
|
1507
1545
|
return body;
|
|
1508
1546
|
}
|
|
1547
|
+
function hasDomainFlag(opts) {
|
|
1548
|
+
return opts.allowedValues !== void 0 || opts.min !== void 0 || opts.max !== void 0;
|
|
1549
|
+
}
|
|
1550
|
+
function parseNumericLiteral(text, flag) {
|
|
1551
|
+
let parsed;
|
|
1552
|
+
try {
|
|
1553
|
+
parsed = parseJson(text);
|
|
1554
|
+
} catch {
|
|
1555
|
+
throw new Error(`${flag} must be a number, got: ${text}`);
|
|
1556
|
+
}
|
|
1557
|
+
if (typeof parsed !== "number" && !isLosslessNumber(parsed)) {
|
|
1558
|
+
throw new Error(`${flag} must be a number, got: ${text}`);
|
|
1559
|
+
}
|
|
1560
|
+
return parsed;
|
|
1561
|
+
}
|
|
1562
|
+
function asDomainValue(text, numeric) {
|
|
1563
|
+
if (numeric === true) return parseNumericLiteral(text, "--allowed-values");
|
|
1564
|
+
if (numeric === false) return text;
|
|
1565
|
+
try {
|
|
1566
|
+
const parsed = parseJson(text);
|
|
1567
|
+
return typeof parsed === "number" || isLosslessNumber(parsed) ? parsed : text;
|
|
1568
|
+
} catch {
|
|
1569
|
+
return text;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
function buildValueDomain(opts, type) {
|
|
1573
|
+
if (!hasDomainFlag(opts)) return void 0;
|
|
1574
|
+
const numeric = type === void 0 ? void 0 : type === "NUMBER" || type === "LIST_NUMBER";
|
|
1575
|
+
const domain = {};
|
|
1576
|
+
if (opts.allowedValues !== void 0) {
|
|
1577
|
+
domain.allowedValues = opts.allowedValues.split(",").map((part) => part.trim()).filter((part) => part.length > 0).map((part) => asDomainValue(part, numeric));
|
|
1578
|
+
}
|
|
1579
|
+
if (opts.min !== void 0) domain.min = parseNumericLiteral(opts.min, "--min");
|
|
1580
|
+
if (opts.max !== void 0) domain.max = parseNumericLiteral(opts.max, "--max");
|
|
1581
|
+
return domain;
|
|
1582
|
+
}
|
|
1509
1583
|
|
|
1510
1584
|
// src/commands/deploy.ts
|
|
1511
1585
|
import "commander";
|
|
@@ -3909,6 +3983,15 @@ function registerFactTools(server, callApi) {
|
|
|
3909
3983
|
isRequired: z5.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
|
|
3910
3984
|
isPii: z5.boolean().default(false).describe(
|
|
3911
3985
|
"Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
|
|
3986
|
+
),
|
|
3987
|
+
valueDomain: z5.object({
|
|
3988
|
+
allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
|
|
3989
|
+
"Accepted values. For list types the constraint applies to each element, not to the list as a whole."
|
|
3990
|
+
),
|
|
3991
|
+
min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
|
|
3992
|
+
max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
|
|
3993
|
+
}).optional().describe(
|
|
3994
|
+
"Which values this fact accepts. Omit for no constraint. STRING and LIST_STRING take allowedValues only; NUMBER and LIST_NUMBER take all three; BOOLEAN takes none. Declaring it lets the rule editor offer a dropdown and rejects a rule literal that could never match."
|
|
3912
3995
|
)
|
|
3913
3996
|
})
|
|
3914
3997
|
},
|
|
@@ -3928,7 +4011,16 @@ function registerFactTools(server, callApi) {
|
|
|
3928
4011
|
"Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule references the fact."
|
|
3929
4012
|
),
|
|
3930
4013
|
isRequired: z5.boolean().optional().describe("Required flag"),
|
|
3931
|
-
isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)")
|
|
4014
|
+
isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
|
|
4015
|
+
valueDomain: z5.object({
|
|
4016
|
+
allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
|
|
4017
|
+
"Accepted values. For list types the constraint applies to each element, not to the list as a whole."
|
|
4018
|
+
),
|
|
4019
|
+
min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
|
|
4020
|
+
max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
|
|
4021
|
+
}).optional().describe(
|
|
4022
|
+
"Which values this fact accepts. Three states: omit the field to leave the constraint alone, send {} to remove it, or send a populated object to replace it. Narrowing it does not rewrite rules that already reference this fact \u2014 they keep working, and the next save is judged against the new constraint."
|
|
4023
|
+
)
|
|
3932
4024
|
})
|
|
3933
4025
|
},
|
|
3934
4026
|
async ({ factId, ...body }) => callApi("PUT", `schema/facts/${factId}`, { body })
|
package/dist/mcp/register.js
CHANGED
|
@@ -586,6 +586,15 @@ function registerFactTools(server, callApi) {
|
|
|
586
586
|
isRequired: z5.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
|
|
587
587
|
isPii: z5.boolean().default(false).describe(
|
|
588
588
|
"Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
|
|
589
|
+
),
|
|
590
|
+
valueDomain: z5.object({
|
|
591
|
+
allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
|
|
592
|
+
"Accepted values. For list types the constraint applies to each element, not to the list as a whole."
|
|
593
|
+
),
|
|
594
|
+
min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
|
|
595
|
+
max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
|
|
596
|
+
}).optional().describe(
|
|
597
|
+
"Which values this fact accepts. Omit for no constraint. STRING and LIST_STRING take allowedValues only; NUMBER and LIST_NUMBER take all three; BOOLEAN takes none. Declaring it lets the rule editor offer a dropdown and rejects a rule literal that could never match."
|
|
589
598
|
)
|
|
590
599
|
})
|
|
591
600
|
},
|
|
@@ -605,7 +614,16 @@ function registerFactTools(server, callApi) {
|
|
|
605
614
|
"Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule references the fact."
|
|
606
615
|
),
|
|
607
616
|
isRequired: z5.boolean().optional().describe("Required flag"),
|
|
608
|
-
isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)")
|
|
617
|
+
isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
|
|
618
|
+
valueDomain: z5.object({
|
|
619
|
+
allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
|
|
620
|
+
"Accepted values. For list types the constraint applies to each element, not to the list as a whole."
|
|
621
|
+
),
|
|
622
|
+
min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
|
|
623
|
+
max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
|
|
624
|
+
}).optional().describe(
|
|
625
|
+
"Which values this fact accepts. Three states: omit the field to leave the constraint alone, send {} to remove it, or send a populated object to replace it. Narrowing it does not rewrite rules that already reference this fact \u2014 they keep working, and the next save is judged against the new constraint."
|
|
626
|
+
)
|
|
609
627
|
})
|
|
610
628
|
},
|
|
611
629
|
async ({ factId, ...body }) => callApi("PUT", `schema/facts/${factId}`, { body })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lexq/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"mcpName": "io.lexq/lexq",
|
|
5
5
|
"description": "LexQ CLI — manage policies, simulate rules, and deploy from the terminal. Built for humans and AI agents.",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit",
|
|
31
31
|
"enums": "node scripts/gen-enums.mjs",
|
|
32
32
|
"enums:check": "node scripts/gen-enums.mjs --check",
|
|
33
|
-
"
|
|
33
|
+
"prose:check": "node scripts/check-prose-mirrors.mjs",
|
|
34
34
|
"surface:check": "node scripts/check-public-surface.mjs",
|
|
35
35
|
"ab-key:check": "node scripts/check-ab-key.mjs",
|
|
36
36
|
"registry:check": "node scripts/check-registry-manifest.mjs",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"test:decimals": "pnpm build && node tests/exact-decimals.mjs",
|
|
42
42
|
"test:fact-key": "node tests/fact-key.mjs",
|
|
43
43
|
"test:export-format": "node tests/export-format.mjs",
|
|
44
|
+
"test:value-domain": "pnpm build && node tests/value-domain.mjs",
|
|
44
45
|
"prepublishOnly": "pnpm build",
|
|
45
46
|
"knip": "knip",
|
|
46
47
|
"format": "prettier --write \"src/**/*.ts\"",
|
|
@@ -20,7 +20,11 @@ lexq versions create --group-id <gid> --json '{"commitMessage": "Initial tiered
|
|
|
20
20
|
|
|
21
21
|
# 3. Register facts (skip if already exist)
|
|
22
22
|
lexq facts create --key paymentAmount --name "Payment Amount" --type NUMBER --required
|
|
23
|
-
lexq facts create --key customerTier --name "Customer Tier" --type STRING
|
|
23
|
+
lexq facts create --key customerTier --name "Customer Tier" --type STRING \
|
|
24
|
+
--allowed-values "VIP, GOLD, SILVER"
|
|
25
|
+
# A value domain says which values the fact accepts. It refuses a rule literal that could
|
|
26
|
+
# never match (a typo like "PLATNUM"), and the Console offers a dropdown instead of a text box.
|
|
27
|
+
# Leave it off when the set is open — a country code, an account id, a free-text label.
|
|
24
28
|
|
|
25
29
|
# 4. Add rules — creation order becomes priority order (first created = priority 1 = highest)
|
|
26
30
|
lexq rules create --group-id <gid> --version-id <vid> --json '{
|
|
@@ -47,6 +47,7 @@ This is the typical lifecycle. **Always follow this order:**
|
|
|
47
47
|
1. lexq groups create → Create a policy group
|
|
48
48
|
2. lexq versions create → Create a DRAFT version inside it
|
|
49
49
|
3. lexq facts create → Register input variables (if not already defined)
|
|
50
|
+
→ --allowed-values / --min / --max constrain what callers may send
|
|
50
51
|
4. lexq rules create → Add rules with conditions + actions
|
|
51
52
|
5. lexq analytics dry-run → Test with sample facts (validate before publish)
|
|
52
53
|
6. lexq deploy publish → DRAFT → ACTIVE (locks the version)
|
|
@@ -186,12 +186,18 @@ rule you expect never appears in `decisionTraces`, check the version's `effectiv
|
|
|
186
186
|
- **empty** — the condition was evaluated and did not match
|
|
187
187
|
- **`Evaluation error: <code>`** — the condition could not be evaluated at all
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
|
192
|
-
|
|
193
|
-
| `
|
|
194
|
-
| `
|
|
189
|
+
Each code says **who fixes it**.
|
|
190
|
+
|
|
191
|
+
| Code | Who fixes it | Meaning |
|
|
192
|
+
|--------------------------------|--------------|------------------------------------------------------------|
|
|
193
|
+
| `FACT_NOT_PROVIDED` | the caller | The rule reads a fact the request did not include |
|
|
194
|
+
| `FACT_TYPE_MISMATCH` | the caller | The value sent has the wrong type for the condition |
|
|
195
|
+
| `CONDITION_VALUE_TYPE_INVALID` | the author | The literal stored in the rule is wrong for its operator |
|
|
196
|
+
| `MALFORMED_RULE` | nobody | The stored rule is structurally invalid. Report it |
|
|
197
|
+
|
|
198
|
+
`CONDITION_VALUE_TYPE_INVALID` is worth escalating. The right-hand side of a condition belongs
|
|
199
|
+
to the rule, not to your request, so changing the payload will not help — the rule fails the
|
|
200
|
+
same way for every caller. It also got past the check that is supposed to reject it on save.
|
|
195
201
|
|
|
196
202
|
A rule from another group referencing facts you did not send yields `FACT_NOT_PROVIDED` — this
|
|
197
203
|
is normal, not an error.
|