@criterionx/cli 0.3.1
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/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +641 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Tomas Maritano
|
|
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,80 @@
|
|
|
1
|
+
# @criterionx/cli
|
|
2
|
+
|
|
3
|
+
CLI for scaffolding Criterion decisions.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @criterionx/cli
|
|
9
|
+
# or
|
|
10
|
+
npx @criterionx/cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
### `criterion init`
|
|
16
|
+
|
|
17
|
+
Initialize a new Criterion project with example decision.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
criterion init
|
|
21
|
+
criterion init --dir my-project
|
|
22
|
+
criterion init --no-install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Creates:
|
|
26
|
+
- `package.json` with dependencies
|
|
27
|
+
- `tsconfig.json` configured for ESM
|
|
28
|
+
- `src/decisions/transaction-risk.ts` - Example decision
|
|
29
|
+
- `src/index.ts` - Example usage
|
|
30
|
+
|
|
31
|
+
### `criterion new decision <name>`
|
|
32
|
+
|
|
33
|
+
Generate a new decision boilerplate.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
criterion new decision user-eligibility
|
|
37
|
+
criterion new decision LoanApproval
|
|
38
|
+
criterion new decision "payment risk" --dir src/decisions
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Creates a decision file with:
|
|
42
|
+
- Input/output/profile schemas
|
|
43
|
+
- Default rule
|
|
44
|
+
- TODO comments for customization
|
|
45
|
+
|
|
46
|
+
### `criterion new profile <name>`
|
|
47
|
+
|
|
48
|
+
Generate a new profile template.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
criterion new profile us-standard
|
|
52
|
+
criterion new profile eu-premium
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### `criterion list` (coming soon)
|
|
56
|
+
|
|
57
|
+
List all decisions in the project.
|
|
58
|
+
|
|
59
|
+
### `criterion validate` (coming soon)
|
|
60
|
+
|
|
61
|
+
Validate all decisions in the project.
|
|
62
|
+
|
|
63
|
+
## Example Workflow
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# 1. Create new project
|
|
67
|
+
criterion init --dir my-decisions
|
|
68
|
+
cd my-decisions
|
|
69
|
+
|
|
70
|
+
# 2. Generate decisions
|
|
71
|
+
criterion new decision loan-approval
|
|
72
|
+
criterion new decision fraud-detection
|
|
73
|
+
|
|
74
|
+
# 3. Run your code
|
|
75
|
+
npx tsx src/index.ts
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/init.ts
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { execSync } from "child_process";
|
|
10
|
+
import pc from "picocolors";
|
|
11
|
+
var PACKAGE_JSON = `{
|
|
12
|
+
"name": "my-criterion-project",
|
|
13
|
+
"version": "1.0.0",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@criterionx/core": "^0.3.0",
|
|
21
|
+
"zod": "^3.22.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"typescript": "^5.3.0"
|
|
25
|
+
}
|
|
26
|
+
}`;
|
|
27
|
+
var TSCONFIG = `{
|
|
28
|
+
"compilerOptions": {
|
|
29
|
+
"target": "ES2022",
|
|
30
|
+
"module": "ESNext",
|
|
31
|
+
"moduleResolution": "bundler",
|
|
32
|
+
"strict": true,
|
|
33
|
+
"esModuleInterop": true,
|
|
34
|
+
"skipLibCheck": true,
|
|
35
|
+
"outDir": "dist",
|
|
36
|
+
"rootDir": "src"
|
|
37
|
+
},
|
|
38
|
+
"include": ["src/**/*"]
|
|
39
|
+
}`;
|
|
40
|
+
var EXAMPLE_DECISION = `import { defineDecision } from "@criterionx/core";
|
|
41
|
+
import { z } from "zod";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Example: Transaction Risk Decision
|
|
45
|
+
*
|
|
46
|
+
* Evaluates the risk level of a transaction based on amount and profile thresholds.
|
|
47
|
+
*/
|
|
48
|
+
export const transactionRisk = defineDecision({
|
|
49
|
+
id: "transaction-risk",
|
|
50
|
+
version: "1.0.0",
|
|
51
|
+
|
|
52
|
+
inputSchema: z.object({
|
|
53
|
+
amount: z.number().positive(),
|
|
54
|
+
currency: z.string().length(3),
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
outputSchema: z.object({
|
|
58
|
+
risk: z.enum(["HIGH", "MEDIUM", "LOW"]),
|
|
59
|
+
reason: z.string(),
|
|
60
|
+
}),
|
|
61
|
+
|
|
62
|
+
profileSchema: z.object({
|
|
63
|
+
highThreshold: z.number(),
|
|
64
|
+
mediumThreshold: z.number(),
|
|
65
|
+
}),
|
|
66
|
+
|
|
67
|
+
rules: [
|
|
68
|
+
{
|
|
69
|
+
id: "high-risk",
|
|
70
|
+
when: (input, profile) => input.amount > profile.highThreshold,
|
|
71
|
+
emit: () => ({ risk: "HIGH", reason: "Amount exceeds high threshold" }),
|
|
72
|
+
explain: (input, profile) =>
|
|
73
|
+
\`Amount \${input.amount} > \${profile.highThreshold}\`,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "medium-risk",
|
|
77
|
+
when: (input, profile) => input.amount > profile.mediumThreshold,
|
|
78
|
+
emit: () => ({ risk: "MEDIUM", reason: "Amount exceeds medium threshold" }),
|
|
79
|
+
explain: (input, profile) =>
|
|
80
|
+
\`Amount \${input.amount} > \${profile.mediumThreshold}\`,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "low-risk",
|
|
84
|
+
when: () => true,
|
|
85
|
+
emit: () => ({ risk: "LOW", reason: "Amount within acceptable range" }),
|
|
86
|
+
explain: () => "Default: amount within limits",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
`;
|
|
91
|
+
var EXAMPLE_MAIN = `import { Engine } from "@criterionx/core";
|
|
92
|
+
import { transactionRisk } from "./decisions/transaction-risk.js";
|
|
93
|
+
|
|
94
|
+
const engine = new Engine();
|
|
95
|
+
|
|
96
|
+
// Example: Evaluate a transaction
|
|
97
|
+
const result = engine.run(
|
|
98
|
+
transactionRisk,
|
|
99
|
+
{ amount: 15000, currency: "USD" },
|
|
100
|
+
{ profile: { highThreshold: 10000, mediumThreshold: 5000 } }
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
console.log("Status:", result.status);
|
|
104
|
+
console.log("Data:", result.data);
|
|
105
|
+
console.log("\\nExplanation:");
|
|
106
|
+
console.log(engine.explain(result));
|
|
107
|
+
`;
|
|
108
|
+
async function initCommand(options) {
|
|
109
|
+
const targetDir = path.resolve(options.dir);
|
|
110
|
+
console.log(pc.cyan("\\n\u{1F3AF} Initializing Criterion project...\\n"));
|
|
111
|
+
const dirs = [
|
|
112
|
+
targetDir,
|
|
113
|
+
path.join(targetDir, "src"),
|
|
114
|
+
path.join(targetDir, "src", "decisions")
|
|
115
|
+
];
|
|
116
|
+
for (const dir of dirs) {
|
|
117
|
+
if (!fs.existsSync(dir)) {
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
119
|
+
console.log(pc.green(" \u2713"), pc.dim("Created"), dir);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const files = [
|
|
123
|
+
{ path: path.join(targetDir, "package.json"), content: PACKAGE_JSON },
|
|
124
|
+
{ path: path.join(targetDir, "tsconfig.json"), content: TSCONFIG },
|
|
125
|
+
{
|
|
126
|
+
path: path.join(targetDir, "src", "decisions", "transaction-risk.ts"),
|
|
127
|
+
content: EXAMPLE_DECISION
|
|
128
|
+
},
|
|
129
|
+
{ path: path.join(targetDir, "src", "index.ts"), content: EXAMPLE_MAIN }
|
|
130
|
+
];
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
if (!fs.existsSync(file.path)) {
|
|
133
|
+
fs.writeFileSync(file.path, file.content);
|
|
134
|
+
console.log(pc.green(" \u2713"), pc.dim("Created"), file.path);
|
|
135
|
+
} else {
|
|
136
|
+
console.log(pc.yellow(" \u26A0"), pc.dim("Exists"), file.path);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (options.install) {
|
|
140
|
+
console.log(pc.cyan("\\n\u{1F4E6} Installing dependencies...\\n"));
|
|
141
|
+
try {
|
|
142
|
+
execSync("npm install", { cwd: targetDir, stdio: "inherit" });
|
|
143
|
+
} catch {
|
|
144
|
+
console.log(pc.yellow("\\n\u26A0 Failed to install dependencies. Run 'npm install' manually."));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
console.log(pc.green("\\n\u2705 Project initialized!\\n"));
|
|
148
|
+
console.log("Next steps:");
|
|
149
|
+
console.log(pc.dim(" cd " + (options.dir === "." ? "." : options.dir)));
|
|
150
|
+
if (!options.install) {
|
|
151
|
+
console.log(pc.dim(" npm install"));
|
|
152
|
+
}
|
|
153
|
+
console.log(pc.dim(" npx tsx src/index.ts"));
|
|
154
|
+
console.log();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/commands/new.ts
|
|
158
|
+
import fs2 from "fs";
|
|
159
|
+
import path2 from "path";
|
|
160
|
+
import pc2 from "picocolors";
|
|
161
|
+
function toKebabCase(str) {
|
|
162
|
+
return str.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
|
|
163
|
+
}
|
|
164
|
+
function toPascalCase(str) {
|
|
165
|
+
return str.split(/[-_\s]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("");
|
|
166
|
+
}
|
|
167
|
+
function toCamelCase(str) {
|
|
168
|
+
const pascal = toPascalCase(str);
|
|
169
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
170
|
+
}
|
|
171
|
+
function generateDecision(name) {
|
|
172
|
+
const id = toKebabCase(name);
|
|
173
|
+
const varName = toCamelCase(name);
|
|
174
|
+
return `import { defineDecision } from "@criterionx/core";
|
|
175
|
+
import { z } from "zod";
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* ${toPascalCase(name)} Decision
|
|
179
|
+
*
|
|
180
|
+
* TODO: Add description
|
|
181
|
+
*/
|
|
182
|
+
export const ${varName} = defineDecision({
|
|
183
|
+
id: "${id}",
|
|
184
|
+
version: "1.0.0",
|
|
185
|
+
|
|
186
|
+
inputSchema: z.object({
|
|
187
|
+
// TODO: Define input schema
|
|
188
|
+
value: z.string(),
|
|
189
|
+
}),
|
|
190
|
+
|
|
191
|
+
outputSchema: z.object({
|
|
192
|
+
// TODO: Define output schema
|
|
193
|
+
result: z.string(),
|
|
194
|
+
}),
|
|
195
|
+
|
|
196
|
+
profileSchema: z.object({
|
|
197
|
+
// TODO: Define profile schema (parameters that can vary)
|
|
198
|
+
}),
|
|
199
|
+
|
|
200
|
+
rules: [
|
|
201
|
+
{
|
|
202
|
+
id: "default",
|
|
203
|
+
when: () => true,
|
|
204
|
+
emit: () => ({ result: "OK" }),
|
|
205
|
+
explain: () => "Default rule",
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
function generateProfile(name) {
|
|
212
|
+
const varName = toCamelCase(name) + "Profile";
|
|
213
|
+
return `/**
|
|
214
|
+
* ${toPascalCase(name)} Profile
|
|
215
|
+
*
|
|
216
|
+
* Profiles parameterize decisions without changing logic.
|
|
217
|
+
* Create different profiles for different regions, tiers, or environments.
|
|
218
|
+
*/
|
|
219
|
+
export const ${varName} = {
|
|
220
|
+
// TODO: Define profile values
|
|
221
|
+
// These should match the profileSchema of your decision
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Example: Multiple profiles for different contexts
|
|
225
|
+
// export const ${varName}US = { threshold: 10000 };
|
|
226
|
+
// export const ${varName}EU = { threshold: 8000 };
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
229
|
+
async function newCommand(type, name, options) {
|
|
230
|
+
const validTypes = ["decision", "profile"];
|
|
231
|
+
if (!validTypes.includes(type)) {
|
|
232
|
+
console.log(pc2.red(`\\n\u274C Invalid type: ${type}`));
|
|
233
|
+
console.log(pc2.dim(` Valid types: ${validTypes.join(", ")}\\n`));
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
const fileName = toKebabCase(name) + ".ts";
|
|
237
|
+
const targetDir = path2.resolve(options.dir);
|
|
238
|
+
const filePath = path2.join(targetDir, fileName);
|
|
239
|
+
console.log(pc2.cyan(`\\n\u{1F3AF} Generating ${type}: ${name}\\n`));
|
|
240
|
+
if (!fs2.existsSync(targetDir)) {
|
|
241
|
+
fs2.mkdirSync(targetDir, { recursive: true });
|
|
242
|
+
console.log(pc2.green(" \u2713"), pc2.dim("Created directory"), targetDir);
|
|
243
|
+
}
|
|
244
|
+
if (fs2.existsSync(filePath)) {
|
|
245
|
+
console.log(pc2.red(`\\n\u274C File already exists: ${filePath}\\n`));
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
let content;
|
|
249
|
+
switch (type) {
|
|
250
|
+
case "decision":
|
|
251
|
+
content = generateDecision(name);
|
|
252
|
+
break;
|
|
253
|
+
case "profile":
|
|
254
|
+
content = generateProfile(name);
|
|
255
|
+
break;
|
|
256
|
+
default:
|
|
257
|
+
throw new Error(`Unknown type: ${type}`);
|
|
258
|
+
}
|
|
259
|
+
fs2.writeFileSync(filePath, content);
|
|
260
|
+
console.log(pc2.green(" \u2713"), pc2.dim("Created"), filePath);
|
|
261
|
+
console.log(pc2.green(`\\n\u2705 ${toPascalCase(type)} created!\\n`));
|
|
262
|
+
console.log("Next steps:");
|
|
263
|
+
console.log(pc2.dim(` 1. Edit ${filePath}`));
|
|
264
|
+
console.log(pc2.dim(` 2. Define your schemas and rules`));
|
|
265
|
+
console.log(pc2.dim(` 3. Import and use in your application\\n`));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/commands/list.ts
|
|
269
|
+
import fs3 from "fs";
|
|
270
|
+
import path3 from "path";
|
|
271
|
+
import pc3 from "picocolors";
|
|
272
|
+
function findDecisionFiles(dir) {
|
|
273
|
+
const files = [];
|
|
274
|
+
function walk(currentDir) {
|
|
275
|
+
if (!fs3.existsSync(currentDir)) return;
|
|
276
|
+
const entries = fs3.readdirSync(currentDir, { withFileTypes: true });
|
|
277
|
+
for (const entry of entries) {
|
|
278
|
+
const fullPath = path3.join(currentDir, entry.name);
|
|
279
|
+
if (entry.isDirectory()) {
|
|
280
|
+
if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
walk(fullPath);
|
|
284
|
+
} else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
|
|
285
|
+
files.push(fullPath);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
walk(dir);
|
|
290
|
+
return files;
|
|
291
|
+
}
|
|
292
|
+
function isDecisionFile(content) {
|
|
293
|
+
return content.includes("defineDecision") && content.includes("@criterionx/core");
|
|
294
|
+
}
|
|
295
|
+
function extractDecisionInfo(content, filePath) {
|
|
296
|
+
const defineDecisionMatch = content.match(/defineDecision\s*\(\s*\{/);
|
|
297
|
+
if (!defineDecisionMatch) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
const startPos = defineDecisionMatch.index;
|
|
301
|
+
const blockEnd = findBlockEnd(content, startPos);
|
|
302
|
+
const block = content.slice(startPos, blockEnd);
|
|
303
|
+
const idMatch = block.match(/id\s*:\s*["'`]([^"'`]+)["'`]/);
|
|
304
|
+
const id = idMatch ? idMatch[1] : "unknown";
|
|
305
|
+
const versionMatch = block.match(/version\s*:\s*["'`]([^"'`]+)["'`]/);
|
|
306
|
+
const version = versionMatch ? versionMatch[1] : "unknown";
|
|
307
|
+
const rulesMatch = block.match(/rules\s*:\s*\[/);
|
|
308
|
+
let rulesCount = 0;
|
|
309
|
+
if (rulesMatch) {
|
|
310
|
+
const rulesStart = rulesMatch.index + rulesMatch[0].length;
|
|
311
|
+
const rulesEnd = findArrayEnd(block, rulesMatch.index);
|
|
312
|
+
const rulesBlock = block.slice(rulesStart, rulesEnd);
|
|
313
|
+
const ruleMatches = rulesBlock.match(/\{\s*id\s*:/g);
|
|
314
|
+
rulesCount = ruleMatches ? ruleMatches.length : 0;
|
|
315
|
+
}
|
|
316
|
+
return { file: filePath, id, version, rulesCount };
|
|
317
|
+
}
|
|
318
|
+
function findBlockEnd(text, start) {
|
|
319
|
+
let depth = 0;
|
|
320
|
+
let inString = false;
|
|
321
|
+
let stringChar = "";
|
|
322
|
+
for (let i = start; i < text.length; i++) {
|
|
323
|
+
const char = text[i];
|
|
324
|
+
const prevChar = i > 0 ? text[i - 1] : "";
|
|
325
|
+
if (inString) {
|
|
326
|
+
if (char === stringChar && prevChar !== "\\") {
|
|
327
|
+
inString = false;
|
|
328
|
+
}
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
332
|
+
inString = true;
|
|
333
|
+
stringChar = char;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (char === "{") {
|
|
337
|
+
depth++;
|
|
338
|
+
} else if (char === "}") {
|
|
339
|
+
depth--;
|
|
340
|
+
if (depth === 0) {
|
|
341
|
+
return i + 1;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return text.length;
|
|
346
|
+
}
|
|
347
|
+
function findArrayEnd(text, start) {
|
|
348
|
+
let depth = 0;
|
|
349
|
+
let inString = false;
|
|
350
|
+
let stringChar = "";
|
|
351
|
+
for (let i = start; i < text.length; i++) {
|
|
352
|
+
const char = text[i];
|
|
353
|
+
const prevChar = i > 0 ? text[i - 1] : "";
|
|
354
|
+
if (inString) {
|
|
355
|
+
if (char === stringChar && prevChar !== "\\") {
|
|
356
|
+
inString = false;
|
|
357
|
+
}
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
361
|
+
inString = true;
|
|
362
|
+
stringChar = char;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (char === "[") {
|
|
366
|
+
depth++;
|
|
367
|
+
} else if (char === "]") {
|
|
368
|
+
depth--;
|
|
369
|
+
if (depth === 0) {
|
|
370
|
+
return i;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return text.length;
|
|
375
|
+
}
|
|
376
|
+
async function listCommand(options) {
|
|
377
|
+
const targetDir = path3.resolve(options.dir);
|
|
378
|
+
if (!fs3.existsSync(targetDir)) {
|
|
379
|
+
if (options.json) {
|
|
380
|
+
console.log(JSON.stringify({ error: "Directory not found", decisions: [] }));
|
|
381
|
+
} else {
|
|
382
|
+
console.log(pc3.red(`
|
|
383
|
+
\u274C Directory not found: ${targetDir}
|
|
384
|
+
`));
|
|
385
|
+
}
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
|
388
|
+
const files = findDecisionFiles(targetDir);
|
|
389
|
+
const decisions = [];
|
|
390
|
+
for (const file of files) {
|
|
391
|
+
const content = fs3.readFileSync(file, "utf-8");
|
|
392
|
+
if (!isDecisionFile(content)) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const info = extractDecisionInfo(content, file);
|
|
396
|
+
if (info) {
|
|
397
|
+
decisions.push(info);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (options.json) {
|
|
401
|
+
console.log(JSON.stringify({ decisions }, null, 2));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (decisions.length === 0) {
|
|
405
|
+
console.log(pc3.yellow("\n\u26A0 No decisions found in"), pc3.dim(targetDir));
|
|
406
|
+
console.log(pc3.dim(" Decisions should import from '@criterionx/core' and use defineDecision()"));
|
|
407
|
+
console.log();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
console.log(pc3.cyan("\n\u{1F4CB} Criterion Decisions\n"));
|
|
411
|
+
const maxIdLen = Math.max(...decisions.map((d) => d.id.length), 2);
|
|
412
|
+
const maxVersionLen = Math.max(...decisions.map((d) => d.version.length), 7);
|
|
413
|
+
console.log(
|
|
414
|
+
pc3.dim(" ") + pc3.bold("ID".padEnd(maxIdLen + 2)) + pc3.bold("VERSION".padEnd(maxVersionLen + 2)) + pc3.bold("RULES".padEnd(7)) + pc3.bold("FILE")
|
|
415
|
+
);
|
|
416
|
+
console.log(pc3.dim(" " + "\u2500".repeat(60)));
|
|
417
|
+
for (const decision of decisions) {
|
|
418
|
+
const relativePath = path3.relative(targetDir, decision.file);
|
|
419
|
+
console.log(
|
|
420
|
+
pc3.dim(" ") + pc3.green(decision.id.padEnd(maxIdLen + 2)) + pc3.cyan(decision.version.padEnd(maxVersionLen + 2)) + String(decision.rulesCount).padEnd(7) + pc3.dim(relativePath)
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
console.log();
|
|
424
|
+
console.log(pc3.dim(` Found ${decisions.length} decision(s)`));
|
|
425
|
+
console.log();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/commands/validate.ts
|
|
429
|
+
import fs4 from "fs";
|
|
430
|
+
import path4 from "path";
|
|
431
|
+
import pc4 from "picocolors";
|
|
432
|
+
function findDecisionFiles2(dir) {
|
|
433
|
+
const files = [];
|
|
434
|
+
function walk(currentDir) {
|
|
435
|
+
if (!fs4.existsSync(currentDir)) return;
|
|
436
|
+
const entries = fs4.readdirSync(currentDir, { withFileTypes: true });
|
|
437
|
+
for (const entry of entries) {
|
|
438
|
+
const fullPath = path4.join(currentDir, entry.name);
|
|
439
|
+
if (entry.isDirectory()) {
|
|
440
|
+
if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
walk(fullPath);
|
|
444
|
+
} else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
|
|
445
|
+
files.push(fullPath);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
walk(dir);
|
|
450
|
+
return files;
|
|
451
|
+
}
|
|
452
|
+
function isDecisionFile2(content) {
|
|
453
|
+
return content.includes("defineDecision") && content.includes("@criterionx/core");
|
|
454
|
+
}
|
|
455
|
+
function validateDecision(content, filePath) {
|
|
456
|
+
const errors = [];
|
|
457
|
+
const warnings = [];
|
|
458
|
+
const defineDecisionMatch = content.match(/defineDecision\s*\(\s*\{/);
|
|
459
|
+
if (!defineDecisionMatch) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
const startPos = defineDecisionMatch.index;
|
|
463
|
+
const blockEnd = findBlockEnd2(content, startPos);
|
|
464
|
+
const block = content.slice(startPos, blockEnd);
|
|
465
|
+
const requiredProps = [
|
|
466
|
+
{ prop: "id:", message: "Missing required 'id' property" },
|
|
467
|
+
{ prop: "version:", message: "Missing required 'version' property" },
|
|
468
|
+
{ prop: "inputSchema:", message: "Missing required 'inputSchema' property" },
|
|
469
|
+
{ prop: "outputSchema:", message: "Missing required 'outputSchema' property" },
|
|
470
|
+
{ prop: "profileSchema:", message: "Missing required 'profileSchema' property" },
|
|
471
|
+
{ prop: "rules:", message: "Missing required 'rules' property" }
|
|
472
|
+
];
|
|
473
|
+
for (const { prop, message } of requiredProps) {
|
|
474
|
+
if (!block.includes(prop)) {
|
|
475
|
+
errors.push(message);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (/rules\s*:\s*\[\s*\]/.test(block)) {
|
|
479
|
+
warnings.push("Decision has empty rules array");
|
|
480
|
+
}
|
|
481
|
+
const rulesMatch = block.match(/rules\s*:\s*\[/);
|
|
482
|
+
if (rulesMatch) {
|
|
483
|
+
const rulesStart = rulesMatch.index + rulesMatch[0].length;
|
|
484
|
+
const rulesEnd = findArrayEnd2(block, rulesMatch.index);
|
|
485
|
+
const rulesBlock = block.slice(rulesStart, rulesEnd);
|
|
486
|
+
const ruleRegex = /\{\s*id\s*:/g;
|
|
487
|
+
let ruleMatch;
|
|
488
|
+
while ((ruleMatch = ruleRegex.exec(rulesBlock)) !== null) {
|
|
489
|
+
const ruleStart = ruleMatch.index;
|
|
490
|
+
const ruleEnd = findBlockEnd2(rulesBlock, ruleStart);
|
|
491
|
+
const rule = rulesBlock.slice(ruleStart, ruleEnd);
|
|
492
|
+
const idMatch = rule.match(/id\s*:\s*["'`]([^"'`]+)["'`]/);
|
|
493
|
+
const ruleId = idMatch ? idMatch[1] : "unknown";
|
|
494
|
+
const requiredFunctions = ["when:", "emit:", "explain:"];
|
|
495
|
+
for (const func of requiredFunctions) {
|
|
496
|
+
if (!rule.includes(func)) {
|
|
497
|
+
errors.push(`Rule '${ruleId}' missing required '${func.replace(":", "")}' function`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
return { file: filePath, errors, warnings };
|
|
506
|
+
}
|
|
507
|
+
function findBlockEnd2(text, start) {
|
|
508
|
+
let depth = 0;
|
|
509
|
+
let inString = false;
|
|
510
|
+
let stringChar = "";
|
|
511
|
+
for (let i = start; i < text.length; i++) {
|
|
512
|
+
const char = text[i];
|
|
513
|
+
const prevChar = i > 0 ? text[i - 1] : "";
|
|
514
|
+
if (inString) {
|
|
515
|
+
if (char === stringChar && prevChar !== "\\") {
|
|
516
|
+
inString = false;
|
|
517
|
+
}
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
521
|
+
inString = true;
|
|
522
|
+
stringChar = char;
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (char === "{") {
|
|
526
|
+
depth++;
|
|
527
|
+
} else if (char === "}") {
|
|
528
|
+
depth--;
|
|
529
|
+
if (depth === 0) {
|
|
530
|
+
return i + 1;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return text.length;
|
|
535
|
+
}
|
|
536
|
+
function findArrayEnd2(text, start) {
|
|
537
|
+
let depth = 0;
|
|
538
|
+
let inString = false;
|
|
539
|
+
let stringChar = "";
|
|
540
|
+
for (let i = start; i < text.length; i++) {
|
|
541
|
+
const char = text[i];
|
|
542
|
+
const prevChar = i > 0 ? text[i - 1] : "";
|
|
543
|
+
if (inString) {
|
|
544
|
+
if (char === stringChar && prevChar !== "\\") {
|
|
545
|
+
inString = false;
|
|
546
|
+
}
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
550
|
+
inString = true;
|
|
551
|
+
stringChar = char;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (char === "[") {
|
|
555
|
+
depth++;
|
|
556
|
+
} else if (char === "]") {
|
|
557
|
+
depth--;
|
|
558
|
+
if (depth === 0) {
|
|
559
|
+
return i;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return text.length;
|
|
564
|
+
}
|
|
565
|
+
async function validateCommand(options) {
|
|
566
|
+
const targetDir = path4.resolve(options.dir);
|
|
567
|
+
console.log(pc4.cyan("\n\u{1F50D} Validating Criterion decisions...\n"));
|
|
568
|
+
if (!fs4.existsSync(targetDir)) {
|
|
569
|
+
console.log(pc4.red(`
|
|
570
|
+
\u274C Directory not found: ${targetDir}
|
|
571
|
+
`));
|
|
572
|
+
process.exit(1);
|
|
573
|
+
}
|
|
574
|
+
const files = findDecisionFiles2(targetDir);
|
|
575
|
+
if (files.length === 0) {
|
|
576
|
+
console.log(pc4.yellow(" No TypeScript files found in"), pc4.dim(targetDir));
|
|
577
|
+
console.log();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
let decisionsFound = 0;
|
|
581
|
+
let decisionsValid = 0;
|
|
582
|
+
const validationErrors = [];
|
|
583
|
+
for (const file of files) {
|
|
584
|
+
const content = fs4.readFileSync(file, "utf-8");
|
|
585
|
+
if (!isDecisionFile2(content)) {
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
decisionsFound++;
|
|
589
|
+
const result = validateDecision(content, file);
|
|
590
|
+
if (result) {
|
|
591
|
+
validationErrors.push(result);
|
|
592
|
+
} else {
|
|
593
|
+
decisionsValid++;
|
|
594
|
+
const relativePath = path4.relative(targetDir, file);
|
|
595
|
+
console.log(pc4.green(" \u2713"), pc4.dim(relativePath));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (validationErrors.length > 0) {
|
|
599
|
+
console.log();
|
|
600
|
+
for (const result of validationErrors) {
|
|
601
|
+
const relativePath = path4.relative(targetDir, result.file);
|
|
602
|
+
console.log(pc4.red(" \u2717"), pc4.dim(relativePath));
|
|
603
|
+
for (const error of result.errors) {
|
|
604
|
+
console.log(pc4.red(" \u2192"), error);
|
|
605
|
+
}
|
|
606
|
+
for (const warning of result.warnings) {
|
|
607
|
+
console.log(pc4.yellow(" \u2192"), warning);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
console.log();
|
|
612
|
+
if (decisionsFound === 0) {
|
|
613
|
+
console.log(pc4.yellow("\u26A0 No decisions found in"), pc4.dim(targetDir));
|
|
614
|
+
console.log(pc4.dim(" Decisions should import from '@criterionx/core' and use defineDecision()"));
|
|
615
|
+
} else if (validationErrors.length === 0) {
|
|
616
|
+
console.log(pc4.green(`\u2705 All ${decisionsFound} decision(s) are valid!`));
|
|
617
|
+
} else {
|
|
618
|
+
const errorCount = validationErrors.reduce((sum, e) => sum + e.errors.length, 0);
|
|
619
|
+
const warningCount = validationErrors.reduce((sum, e) => sum + e.warnings.length, 0);
|
|
620
|
+
console.log(
|
|
621
|
+
pc4.red(`\u274C Found issues in ${validationErrors.length} of ${decisionsFound} decision(s)`)
|
|
622
|
+
);
|
|
623
|
+
if (errorCount > 0) {
|
|
624
|
+
console.log(pc4.red(` ${errorCount} error(s)`));
|
|
625
|
+
}
|
|
626
|
+
if (warningCount > 0) {
|
|
627
|
+
console.log(pc4.yellow(` ${warningCount} warning(s)`));
|
|
628
|
+
}
|
|
629
|
+
process.exit(1);
|
|
630
|
+
}
|
|
631
|
+
console.log();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// src/index.ts
|
|
635
|
+
var program = new Command();
|
|
636
|
+
program.name("criterion").description("CLI for scaffolding and managing Criterion decisions").version("0.3.1");
|
|
637
|
+
program.command("init").description("Initialize a new Criterion project").option("-d, --dir <directory>", "Target directory", ".").option("--no-install", "Skip npm install").action(initCommand);
|
|
638
|
+
program.command("new").description("Generate new Criterion components").argument("<type>", "Type to generate (decision, profile)").argument("<name>", "Name of the component").option("-d, --dir <directory>", "Target directory", "src/decisions").action(newCommand);
|
|
639
|
+
program.command("list").description("List all decisions in the project").option("-d, --dir <directory>", "Directory to search", ".").option("--json", "Output as JSON").action(listCommand);
|
|
640
|
+
program.command("validate").description("Validate all decisions in the project").option("-d, --dir <directory>", "Directory to validate", ".").action(validateCommand);
|
|
641
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@criterionx/cli",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "CLI for scaffolding Criterion decisions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"criterion": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"templates",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"criterion",
|
|
19
|
+
"cli",
|
|
20
|
+
"decision-engine",
|
|
21
|
+
"scaffolding",
|
|
22
|
+
"generator"
|
|
23
|
+
],
|
|
24
|
+
"author": {
|
|
25
|
+
"name": "Tomas Maritano",
|
|
26
|
+
"url": "https://github.com/tomymaritano"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/tomymaritano/criterionx.git",
|
|
32
|
+
"directory": "packages/cli"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/tomymaritano/criterionx/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/tomymaritano/criterionx#readme",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"commander": "^12.0.0",
|
|
40
|
+
"picocolors": "^1.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^20.0.0",
|
|
44
|
+
"tsup": "^8.0.0",
|
|
45
|
+
"tsx": "^4.0.0",
|
|
46
|
+
"typescript": "^5.3.0",
|
|
47
|
+
"vitest": "^4.0.0"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
54
|
+
"dev": "tsx src/index.ts",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"test:watch": "vitest",
|
|
57
|
+
"test:coverage": "vitest run --coverage",
|
|
58
|
+
"typecheck": "tsc --noEmit"
|
|
59
|
+
}
|
|
60
|
+
}
|