slapify 0.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/dist/cli.js ADDED
@@ -0,0 +1,1181 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import chalk from "chalk";
4
+ import ora from "ora";
5
+ import path from "path";
6
+ import fs from "fs";
7
+ import dotenv from "dotenv";
8
+ import { loadConfig, loadCredentials, getConfigDir, } from "./config/loader.js";
9
+ import { parseFlowFile, findFlowFiles, validateFlowFile, getFlowSummary, } from "./parser/flow.js";
10
+ import { TestRunner } from "./runner/index.js";
11
+ import { ReportGenerator } from "./report/generator.js";
12
+ import { BrowserAgent } from "./browser/agent.js";
13
+ import { generateText } from "ai";
14
+ import { createAnthropic } from "@ai-sdk/anthropic";
15
+ import { createOpenAI } from "@ai-sdk/openai";
16
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
17
+ import { createMistral } from "@ai-sdk/mistral";
18
+ import { createGroq } from "@ai-sdk/groq";
19
+ // Load environment variables
20
+ dotenv.config();
21
+ /**
22
+ * Get AI model based on config
23
+ */
24
+ function getModelFromConfig(llmConfig) {
25
+ switch (llmConfig.provider) {
26
+ case "anthropic": {
27
+ const anthropic = createAnthropic({ apiKey: llmConfig.api_key });
28
+ return anthropic(llmConfig.model);
29
+ }
30
+ case "openai": {
31
+ const openai = createOpenAI({ apiKey: llmConfig.api_key });
32
+ return openai(llmConfig.model);
33
+ }
34
+ case "google": {
35
+ const google = createGoogleGenerativeAI({ apiKey: llmConfig.api_key });
36
+ return google(llmConfig.model);
37
+ }
38
+ case "mistral": {
39
+ const mistral = createMistral({ apiKey: llmConfig.api_key });
40
+ return mistral(llmConfig.model);
41
+ }
42
+ case "groq": {
43
+ const groq = createGroq({ apiKey: llmConfig.api_key });
44
+ return groq(llmConfig.model);
45
+ }
46
+ case "ollama": {
47
+ const ollama = createOpenAI({
48
+ apiKey: "ollama",
49
+ baseURL: llmConfig.base_url || "http://localhost:11434/v1",
50
+ });
51
+ return ollama(llmConfig.model);
52
+ }
53
+ default:
54
+ throw new Error(`Unsupported provider: ${llmConfig.provider}`);
55
+ }
56
+ }
57
+ const program = new Command();
58
+ program
59
+ .name("slapify")
60
+ .description("AI-powered test automation using natural language flow files")
61
+ .version("0.1.0");
62
+ // Init command
63
+ program
64
+ .command("init")
65
+ .description("Initialize Slapify in the current directory")
66
+ .option("-y, --yes", "Skip prompts and use defaults")
67
+ .action(async (options) => {
68
+ const readline = await import("readline");
69
+ // Check if already initialized
70
+ if (fs.existsSync(".slapify")) {
71
+ console.log(chalk.yellow("Slapify is already initialized in this directory."));
72
+ console.log(chalk.gray("Delete .slapify folder to reinitialize."));
73
+ return;
74
+ }
75
+ console.log(chalk.blue.bold("\n🖐️ Welcome to Slapify!\n"));
76
+ console.log(chalk.gray("AI-powered E2E testing that slaps - by slaps.dev\n"));
77
+ // Import the new functions
78
+ const { findSystemBrowsers, initConfig: doInit } = await import("./config/loader.js");
79
+ let provider = "anthropic";
80
+ let model;
81
+ let browserPath;
82
+ let useSystemBrowser;
83
+ const providerInfo = {
84
+ anthropic: {
85
+ name: "Anthropic (Claude)",
86
+ envVar: "ANTHROPIC_API_KEY",
87
+ defaultModel: "claude-haiku-4-5-20251001",
88
+ models: [
89
+ {
90
+ id: "claude-haiku-4-5-20251001",
91
+ name: "Haiku 4.5 - fast & cheap ($1/5M tokens)",
92
+ recommended: true,
93
+ },
94
+ {
95
+ id: "claude-sonnet-4-20250514",
96
+ name: "Sonnet 4 - more capable ($3/15M tokens)",
97
+ },
98
+ { id: "custom", name: "Enter custom model ID" },
99
+ ],
100
+ },
101
+ openai: {
102
+ name: "OpenAI",
103
+ envVar: "OPENAI_API_KEY",
104
+ defaultModel: "gpt-4o-mini",
105
+ models: [
106
+ {
107
+ id: "gpt-4o-mini",
108
+ name: "GPT-4o Mini - fast & cheap ($0.15/0.6M tokens)",
109
+ recommended: true,
110
+ },
111
+ { id: "gpt-4.1-mini", name: "GPT-4.1 Mini - newer" },
112
+ { id: "gpt-4o", name: "GPT-4o - more capable ($2.5/10M tokens)" },
113
+ { id: "custom", name: "Enter custom model ID" },
114
+ ],
115
+ },
116
+ google: {
117
+ name: "Google (Gemini)",
118
+ envVar: "GOOGLE_API_KEY",
119
+ defaultModel: "gemini-2.0-flash",
120
+ models: [
121
+ {
122
+ id: "gemini-2.0-flash",
123
+ name: "Gemini 2.0 Flash - fastest & cheapest",
124
+ recommended: true,
125
+ },
126
+ { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash - stable" },
127
+ { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro - more capable" },
128
+ { id: "custom", name: "Enter custom model ID" },
129
+ ],
130
+ },
131
+ mistral: {
132
+ name: "Mistral",
133
+ envVar: "MISTRAL_API_KEY",
134
+ askModel: true,
135
+ defaultModel: "mistral-small-latest",
136
+ },
137
+ groq: {
138
+ name: "Groq (Fast inference)",
139
+ envVar: "GROQ_API_KEY",
140
+ askModel: true,
141
+ defaultModel: "llama-3.3-70b-versatile",
142
+ },
143
+ ollama: {
144
+ name: "Ollama (Local)",
145
+ envVar: "",
146
+ askModel: true,
147
+ defaultModel: "llama3",
148
+ },
149
+ };
150
+ if (options.yes) {
151
+ // Use defaults
152
+ console.log(chalk.gray("Using default settings...\n"));
153
+ }
154
+ else {
155
+ const rl = readline.createInterface({
156
+ input: process.stdin,
157
+ output: process.stdout,
158
+ });
159
+ const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
160
+ // Step 1: LLM Provider
161
+ console.log(chalk.cyan("1. Choose your LLM provider:\n"));
162
+ console.log(" 1) Anthropic (Claude) " + chalk.green("- recommended"));
163
+ console.log(" 2) OpenAI (GPT-4)");
164
+ console.log(" 3) Google (Gemini)");
165
+ console.log(" 4) Mistral");
166
+ console.log(" 5) Groq " + chalk.gray("- fast & free tier"));
167
+ console.log(" 6) Ollama " + chalk.gray("- local, no API key"));
168
+ console.log("");
169
+ const providerChoice = await question(chalk.white(" Select [1]: "));
170
+ const providerMap = {
171
+ "1": "anthropic",
172
+ "2": "openai",
173
+ "3": "google",
174
+ "4": "mistral",
175
+ "5": "groq",
176
+ "6": "ollama",
177
+ };
178
+ provider = providerMap[providerChoice] || "anthropic";
179
+ const info = providerInfo[provider];
180
+ console.log(chalk.green(` ✓ Using ${info.name}\n`));
181
+ // Step 1b: Model Selection
182
+ if (info.models && info.models.length > 0) {
183
+ console.log(chalk.cyan(" Choose model:\n"));
184
+ info.models.forEach((m, i) => {
185
+ const rec = m.recommended ? chalk.green(" ← recommended") : "";
186
+ console.log(` ${i + 1}) ${m.name}${rec}`);
187
+ });
188
+ console.log("");
189
+ const modelChoice = await question(chalk.white(" Select [1]: "));
190
+ const modelIdx = parseInt(modelChoice) - 1 || 0;
191
+ const selectedModel = info.models[modelIdx];
192
+ if (selectedModel?.id === "custom") {
193
+ const customModel = await question(chalk.white(" Enter model ID: "));
194
+ model = customModel.trim() || info.defaultModel;
195
+ }
196
+ else {
197
+ model = selectedModel?.id || info.defaultModel;
198
+ }
199
+ console.log(chalk.green(` ✓ Using model: ${model}\n`));
200
+ }
201
+ else if (info.askModel) {
202
+ // For Mistral, Groq, Ollama - ask for model ID directly
203
+ console.log(chalk.gray(` Enter model ID (default: ${info.defaultModel})`));
204
+ if (provider === "ollama") {
205
+ console.log(chalk.gray(" Common models: llama3, mistral, codellama, phi3"));
206
+ }
207
+ else if (provider === "groq") {
208
+ console.log(chalk.gray(" Common models: llama-3.3-70b-versatile, mixtral-8x7b-32768"));
209
+ }
210
+ else if (provider === "mistral") {
211
+ console.log(chalk.gray(" Common models: mistral-small-latest, mistral-large-latest"));
212
+ }
213
+ console.log("");
214
+ const modelInput = await question(chalk.white(` Model [${info.defaultModel}]: `));
215
+ model = modelInput.trim() || info.defaultModel;
216
+ console.log(chalk.green(` ✓ Using model: ${model}\n`));
217
+ if (provider === "ollama") {
218
+ console.log(chalk.gray(" Make sure Ollama is running: ollama serve"));
219
+ console.log("");
220
+ }
221
+ }
222
+ // Step 2: API Key Verification (skip for Ollama)
223
+ if (provider !== "ollama") {
224
+ console.log(chalk.cyan("2. API Key verification:\n"));
225
+ const envVar = info.envVar;
226
+ let apiKey = process.env[envVar];
227
+ if (apiKey) {
228
+ console.log(chalk.gray(` Found ${envVar} in environment`));
229
+ }
230
+ else {
231
+ console.log(chalk.yellow(` ${envVar} not found in environment`));
232
+ console.log(chalk.gray(" You can set it now or add it to your shell config later.\n"));
233
+ const keyInput = await question(chalk.white(` Enter API key (or press Enter to skip): `));
234
+ if (keyInput.trim()) {
235
+ apiKey = keyInput.trim();
236
+ }
237
+ }
238
+ if (apiKey) {
239
+ const verifyChoice = await question(chalk.white(" Verify API key with test call? (Y/n): "));
240
+ if (verifyChoice.toLowerCase() !== "n") {
241
+ const verifySpinner = ora(" Verifying API key...").start();
242
+ try {
243
+ // Make a minimal test call
244
+ const testConfig = {
245
+ provider,
246
+ model: model || info.defaultModel || "test",
247
+ api_key: apiKey,
248
+ };
249
+ const testModel = getModelFromConfig(testConfig);
250
+ const response = await generateText({
251
+ model: testModel,
252
+ prompt: "Reply with only the word 'pong'",
253
+ maxTokens: 10,
254
+ });
255
+ if (response.text.toLowerCase().includes("pong")) {
256
+ verifySpinner.succeed(chalk.green("API key verified! ✓"));
257
+ }
258
+ else {
259
+ verifySpinner.succeed(chalk.green("API key works! (got response)"));
260
+ }
261
+ }
262
+ catch (error) {
263
+ verifySpinner.fail(chalk.red("API key verification failed"));
264
+ console.log(chalk.red(` Error: ${error.message}`));
265
+ console.log(chalk.yellow("\n You can still continue - check your API key later."));
266
+ }
267
+ }
268
+ else {
269
+ console.log(chalk.gray(" Skipping verification\n"));
270
+ }
271
+ }
272
+ else {
273
+ console.log(chalk.yellow(`\n Remember to set ${envVar} before running tests.`));
274
+ }
275
+ console.log("");
276
+ }
277
+ // Step 3: Browser Setup
278
+ console.log(chalk.cyan(`${provider === "ollama" ? "2" : "3"}. Browser setup:\n`));
279
+ const systemBrowsers = findSystemBrowsers();
280
+ if (systemBrowsers.length > 0) {
281
+ console.log(" Found browsers on your system:");
282
+ systemBrowsers.forEach((b, i) => {
283
+ console.log(chalk.gray(` ${i + 1}) ${b.name}`));
284
+ });
285
+ console.log(chalk.gray(` ${systemBrowsers.length + 1}) Download Chromium (~170MB)`));
286
+ console.log(chalk.gray(` ${systemBrowsers.length + 2}) Enter custom path`));
287
+ console.log("");
288
+ const browserChoice = await question(chalk.white(` Select [1]: `));
289
+ const choiceNum = parseInt(browserChoice) || 1;
290
+ if (choiceNum <= systemBrowsers.length) {
291
+ browserPath = systemBrowsers[choiceNum - 1].path;
292
+ useSystemBrowser = true;
293
+ console.log(chalk.green(` ✓ Using ${systemBrowsers[choiceNum - 1].name}\n`));
294
+ }
295
+ else if (choiceNum === systemBrowsers.length + 2) {
296
+ const customPath = await question(chalk.white(" Enter browser path: "));
297
+ if (customPath.trim()) {
298
+ browserPath = customPath.trim();
299
+ useSystemBrowser = true;
300
+ console.log(chalk.green(` ✓ Using custom browser\n`));
301
+ }
302
+ }
303
+ else {
304
+ useSystemBrowser = false;
305
+ console.log(chalk.green(" ✓ Will download Chromium on first run\n"));
306
+ }
307
+ }
308
+ else {
309
+ console.log(" No browsers found. Options:");
310
+ console.log(chalk.gray(" 1) Download Chromium automatically (~170MB)"));
311
+ console.log(chalk.gray(" 2) Enter custom browser path"));
312
+ console.log("");
313
+ const browserChoice = await question(chalk.white(" Select [1]: "));
314
+ if (browserChoice === "2") {
315
+ const customPath = await question(chalk.white(" Enter browser path: "));
316
+ if (customPath.trim()) {
317
+ browserPath = customPath.trim();
318
+ useSystemBrowser = true;
319
+ console.log(chalk.green(" ✓ Using custom browser\n"));
320
+ }
321
+ }
322
+ else {
323
+ useSystemBrowser = false;
324
+ console.log(chalk.green(" ✓ Will download Chromium on first run\n"));
325
+ }
326
+ }
327
+ rl.close();
328
+ }
329
+ // Initialize
330
+ const spinner = ora("Creating configuration...").start();
331
+ try {
332
+ doInit(process.cwd(), {
333
+ provider,
334
+ model,
335
+ browserPath,
336
+ useSystemBrowser,
337
+ });
338
+ spinner.succeed("Slapify initialized!");
339
+ console.log("");
340
+ console.log(chalk.green("Created:"));
341
+ console.log(" 📁 .slapify/config.yaml - Configuration");
342
+ console.log(" 🔐 .slapify/credentials.yaml - Credentials (gitignored)");
343
+ console.log(" 📝 tests/example.flow - Sample test");
344
+ console.log("");
345
+ const info = providerInfo[provider];
346
+ console.log(chalk.yellow("Next steps:"));
347
+ console.log("");
348
+ if (provider === "ollama") {
349
+ console.log(chalk.white(` 1. Make sure Ollama is running:`));
350
+ console.log(chalk.cyan(` ollama serve`));
351
+ console.log(chalk.cyan(` ollama pull llama3`));
352
+ }
353
+ else {
354
+ console.log(chalk.white(` 1. Set your API key:`));
355
+ console.log(chalk.cyan(` export ${info.envVar}=your-key-here`));
356
+ }
357
+ console.log("");
358
+ console.log(chalk.white(` 2. Run the example test:`));
359
+ console.log(chalk.cyan(` slapify run tests/example.flow`));
360
+ console.log("");
361
+ console.log(chalk.white(` 3. Create your own tests:`));
362
+ console.log(chalk.cyan(` slapify create my-first-test`));
363
+ console.log(chalk.cyan(` slapify generate "test login for myapp.com"`));
364
+ console.log("");
365
+ console.log(chalk.gray(" Config can be modified anytime in .slapify/config.yaml"));
366
+ console.log("");
367
+ }
368
+ catch (error) {
369
+ spinner.fail(error.message);
370
+ process.exit(1);
371
+ }
372
+ });
373
+ // Install command (for agent-browser)
374
+ program
375
+ .command("install")
376
+ .description("Install browser dependencies")
377
+ .action(() => {
378
+ const spinner = ora("Checking agent-browser...").start();
379
+ if (BrowserAgent.isInstalled()) {
380
+ spinner.succeed("agent-browser is already installed");
381
+ }
382
+ else {
383
+ spinner.text = "Installing agent-browser...";
384
+ try {
385
+ BrowserAgent.install();
386
+ spinner.succeed("Browser dependencies installed!");
387
+ }
388
+ catch (error) {
389
+ spinner.fail(`Installation failed: ${error.message}`);
390
+ process.exit(1);
391
+ }
392
+ }
393
+ });
394
+ // Run command
395
+ program
396
+ .command("run [files...]")
397
+ .description("Run flow test files")
398
+ .option("--headed", "Run browser in headed mode (visible)")
399
+ .option("--report [format]", "Generate report folder (html, markdown, json)")
400
+ .option("--output <dir>", "Output directory for reports", "./test-reports")
401
+ .option("--credentials <profile>", "Default credentials profile to use")
402
+ .option("-p, --parallel", "Run tests in parallel")
403
+ .option("-w, --workers <n>", "Number of parallel workers (default: 4)", "4")
404
+ .action(async (files, options) => {
405
+ try {
406
+ // Load configuration
407
+ const configDir = getConfigDir();
408
+ if (!configDir) {
409
+ console.log(chalk.red('No .slapify directory found. Run "slapify init" first.'));
410
+ process.exit(1);
411
+ }
412
+ const config = loadConfig(configDir);
413
+ const credentials = loadCredentials(configDir);
414
+ // Apply CLI options
415
+ if (options.headed) {
416
+ config.browser = { ...config.browser, headless: false };
417
+ }
418
+ // Only enable screenshots if report is requested
419
+ const generateReport = options.report !== undefined;
420
+ config.report = {
421
+ ...config.report,
422
+ format: typeof options.report === "string" ? options.report : "html",
423
+ output_dir: options.output,
424
+ screenshots: generateReport, // Only take screenshots for reports
425
+ };
426
+ // Find flow files
427
+ let flowFiles = [];
428
+ if (files.length === 0) {
429
+ // Run all flow files in tests directory
430
+ const testsDir = path.join(process.cwd(), "tests");
431
+ if (fs.existsSync(testsDir)) {
432
+ flowFiles = await findFlowFiles(testsDir);
433
+ }
434
+ }
435
+ else {
436
+ for (const file of files) {
437
+ if (fs.statSync(file).isDirectory()) {
438
+ const found = await findFlowFiles(file);
439
+ flowFiles.push(...found);
440
+ }
441
+ else {
442
+ flowFiles.push(file);
443
+ }
444
+ }
445
+ }
446
+ if (flowFiles.length === 0) {
447
+ console.log(chalk.yellow("No .flow files found to run."));
448
+ process.exit(0);
449
+ }
450
+ const reporter = new ReportGenerator(config.report);
451
+ const results = [];
452
+ const isParallel = options.parallel && flowFiles.length > 1;
453
+ const workers = parseInt(options.workers) || 4;
454
+ if (isParallel) {
455
+ // Parallel execution
456
+ console.log(chalk.blue.bold(`\n━━━ Running ${flowFiles.length} tests in parallel (${workers} workers) ━━━\n`));
457
+ const pending = [...flowFiles];
458
+ const running = new Map();
459
+ const testStatus = new Map();
460
+ // Initialize status
461
+ for (const file of flowFiles) {
462
+ const name = path.basename(file, ".flow");
463
+ testStatus.set(name, chalk.gray("⏳ pending"));
464
+ }
465
+ const printStatus = () => {
466
+ // Clear and reprint status
467
+ process.stdout.write("\x1B[" + flowFiles.length + "A"); // Move cursor up
468
+ for (const file of flowFiles) {
469
+ const name = path.basename(file, ".flow");
470
+ const status = testStatus.get(name) || "";
471
+ process.stdout.write("\x1B[2K"); // Clear line
472
+ console.log(` ${name}: ${status}`);
473
+ }
474
+ };
475
+ // Print initial status
476
+ for (const file of flowFiles) {
477
+ const name = path.basename(file, ".flow");
478
+ console.log(` ${name}: ${testStatus.get(name)}`);
479
+ }
480
+ const runTest = async (file) => {
481
+ const flow = parseFlowFile(file);
482
+ const name = flow.name;
483
+ testStatus.set(name, chalk.cyan("▶ running..."));
484
+ printStatus();
485
+ try {
486
+ const runner = new TestRunner(config, credentials);
487
+ const result = await runner.runFlow(flow);
488
+ results.push(result);
489
+ if (result.status === "passed") {
490
+ testStatus.set(name, chalk.green(`✓ passed (${result.passedSteps}/${result.totalSteps} steps, ${(result.duration / 1000).toFixed(1)}s)`));
491
+ }
492
+ else {
493
+ testStatus.set(name, chalk.red(`✗ failed (${result.failedSteps} failed, ${result.passedSteps} passed)`));
494
+ }
495
+ }
496
+ catch (error) {
497
+ testStatus.set(name, chalk.red(`✗ error: ${error.message}`));
498
+ }
499
+ printStatus();
500
+ };
501
+ // Process with worker limit
502
+ while (pending.length > 0 || running.size > 0) {
503
+ // Start new tasks up to worker limit
504
+ while (pending.length > 0 && running.size < workers) {
505
+ const file = pending.shift();
506
+ const promise = runTest(file).then(() => {
507
+ running.delete(file);
508
+ });
509
+ running.set(file, promise);
510
+ }
511
+ // Wait for at least one to complete
512
+ if (running.size > 0) {
513
+ await Promise.race(running.values());
514
+ }
515
+ }
516
+ console.log(""); // Extra newline after parallel run
517
+ }
518
+ else {
519
+ // Sequential execution
520
+ for (const file of flowFiles) {
521
+ const flow = parseFlowFile(file);
522
+ const summary = getFlowSummary(flow);
523
+ // Print test header
524
+ console.log("");
525
+ console.log(chalk.blue.bold(`━━━ ${flow.name} ━━━`));
526
+ console.log(chalk.gray(` ${summary.totalSteps} steps (${summary.requiredSteps} required, ${summary.optionalSteps} optional)`));
527
+ console.log("");
528
+ try {
529
+ const runner = new TestRunner(config, credentials);
530
+ const result = await runner.runFlow(flow, (stepResult) => {
531
+ // Real-time step output
532
+ const step = stepResult.step;
533
+ const statusIcon = stepResult.status === "passed"
534
+ ? chalk.green("✓")
535
+ : stepResult.status === "failed"
536
+ ? chalk.red("✗")
537
+ : chalk.yellow("⊘");
538
+ const optionalTag = step.optional
539
+ ? chalk.gray(" [optional]")
540
+ : "";
541
+ const retriedTag = stepResult.retried
542
+ ? chalk.yellow(" [retried]")
543
+ : "";
544
+ const duration = chalk.gray(`(${(stepResult.duration / 1000).toFixed(1)}s)`);
545
+ console.log(` ${statusIcon} ${step.text}${optionalTag}${retriedTag} ${duration}`);
546
+ // Show error inline if failed
547
+ if (stepResult.status === "failed" && stepResult.error) {
548
+ console.log(chalk.red(` └─ ${stepResult.error}`));
549
+ }
550
+ // Show assumptions if any
551
+ if (stepResult.assumptions && stepResult.assumptions.length > 0) {
552
+ for (const assumption of stepResult.assumptions) {
553
+ console.log(chalk.gray(` └─ 💡 ${assumption}`));
554
+ }
555
+ }
556
+ });
557
+ results.push(result);
558
+ // Print test summary
559
+ console.log("");
560
+ if (result.status === "passed") {
561
+ console.log(chalk.green.bold(` ✓ PASSED`) +
562
+ chalk.gray(` (${result.passedSteps}/${result.totalSteps} steps in ${(result.duration / 1000).toFixed(1)}s)`));
563
+ }
564
+ else {
565
+ console.log(chalk.red.bold(` ✗ FAILED`) +
566
+ chalk.gray(` (${result.failedSteps} failed, ${result.passedSteps} passed)`));
567
+ }
568
+ // Auto-handled info
569
+ if (result.autoHandled.length > 0) {
570
+ console.log(chalk.gray(` ℹ Auto-handled: ${result.autoHandled.join(", ")}`));
571
+ }
572
+ }
573
+ catch (error) {
574
+ console.log(chalk.red(` ✗ ERROR: ${error.message}`));
575
+ }
576
+ }
577
+ }
578
+ // Final summary
579
+ console.log("");
580
+ console.log(chalk.blue.bold("━━━ Summary ━━━"));
581
+ const passed = results.filter((r) => r.status === "passed").length;
582
+ const failed = results.filter((r) => r.status === "failed").length;
583
+ const totalSteps = results.reduce((sum, r) => sum + r.totalSteps, 0);
584
+ const passedSteps = results.reduce((sum, r) => sum + r.passedSteps, 0);
585
+ console.log(chalk.gray(` ${results.length} test file(s), ${totalSteps} total steps`));
586
+ if (failed === 0) {
587
+ console.log(chalk.green.bold(` ✓ All ${passed} test(s) passed! (${passedSteps}/${totalSteps} steps)`));
588
+ }
589
+ else {
590
+ console.log(chalk.red.bold(` ✗ ${failed}/${results.length} test(s) failed`));
591
+ }
592
+ // Generate suite report if requested and multiple files
593
+ if (generateReport && results.length > 0) {
594
+ let reportPath;
595
+ if (results.length === 1) {
596
+ reportPath = reporter.saveAsFolder(results[0]);
597
+ }
598
+ else {
599
+ reportPath = reporter.saveSuiteAsFolder(results);
600
+ }
601
+ console.log(chalk.cyan(`\n 📄 Report: ${reportPath}`));
602
+ }
603
+ console.log("");
604
+ if (failed > 0) {
605
+ process.exit(1);
606
+ }
607
+ }
608
+ catch (error) {
609
+ console.error(chalk.red(`Error: ${error.message}`));
610
+ process.exit(1);
611
+ }
612
+ });
613
+ // Create command
614
+ program
615
+ .command("create <name>")
616
+ .description("Create a new flow file")
617
+ .option("-d, --dir <directory>", "Directory to create flow in", "tests")
618
+ .action(async (name, options) => {
619
+ const readline = await import("readline");
620
+ // Ensure directory exists
621
+ const dir = options.dir;
622
+ if (!fs.existsSync(dir)) {
623
+ fs.mkdirSync(dir, { recursive: true });
624
+ }
625
+ // Generate filename
626
+ const filename = name.endsWith(".flow") ? name : `${name}.flow`;
627
+ const filepath = path.join(dir, filename);
628
+ if (fs.existsSync(filepath)) {
629
+ console.log(chalk.red(`File already exists: ${filepath}`));
630
+ process.exit(1);
631
+ }
632
+ console.log(chalk.blue(`\nCreating: ${filepath}`));
633
+ console.log(chalk.gray("Enter your test steps (one per line). Empty line to finish.\n"));
634
+ const rl = readline.createInterface({
635
+ input: process.stdin,
636
+ output: process.stdout,
637
+ });
638
+ const lines = [`# ${name}`, ""];
639
+ let lineNum = 1;
640
+ const askLine = () => {
641
+ return new Promise((resolve) => {
642
+ rl.question(chalk.cyan(`${lineNum}. `), (answer) => {
643
+ resolve(answer);
644
+ });
645
+ });
646
+ };
647
+ while (true) {
648
+ const line = await askLine();
649
+ if (line === "") {
650
+ break;
651
+ }
652
+ lines.push(line);
653
+ lineNum++;
654
+ }
655
+ rl.close();
656
+ if (lines.length <= 2) {
657
+ console.log(chalk.yellow("\nNo steps entered. File not created."));
658
+ return;
659
+ }
660
+ // Write file
661
+ fs.writeFileSync(filepath, lines.join("\n") + "\n");
662
+ console.log(chalk.green(`\n✓ Created: ${filepath}`));
663
+ console.log(chalk.gray(` ${lineNum - 1} steps`));
664
+ console.log(chalk.gray(`\nRun with: slapify run ${filepath}`));
665
+ });
666
+ // Generate command - AI-powered flow generation
667
+ program
668
+ .command("generate <prompt>")
669
+ .alias("gen")
670
+ .description("Generate a flow file from a natural language prompt")
671
+ .option("-d, --dir <directory>", "Directory to save flow", "tests")
672
+ .option("--headed", "Show browser while analyzing")
673
+ .action(async (prompt, options) => {
674
+ const configDir = getConfigDir();
675
+ if (!configDir) {
676
+ console.log(chalk.red('No .slapify directory found. Run "slapify init" first.'));
677
+ process.exit(1);
678
+ }
679
+ const config = loadConfig(configDir);
680
+ const readline = await import("readline");
681
+ console.log(chalk.blue("\n🤖 AI Flow Generator\n"));
682
+ // Step 1: Extract URL from prompt using AI
683
+ let spinner = ora("Analyzing prompt...").start();
684
+ const urlResponse = await generateText({
685
+ model: getModelFromConfig(config.llm),
686
+ prompt: `Extract the URL from this test request. If there's no URL mentioned, respond with "NO_URL".
687
+
688
+ Request: "${prompt}"
689
+
690
+ Respond with ONLY the URL (e.g., https://example.com) or "NO_URL". Nothing else.`,
691
+ maxTokens: 100,
692
+ });
693
+ let url = urlResponse.text.trim();
694
+ spinner.stop();
695
+ // Step 2: If no URL, ask user
696
+ if (url === "NO_URL" || !url.startsWith("http")) {
697
+ const rl = readline.createInterface({
698
+ input: process.stdin,
699
+ output: process.stdout,
700
+ });
701
+ url = await new Promise((resolve) => {
702
+ rl.question(chalk.cyan("Enter the URL to test: "), (answer) => {
703
+ rl.close();
704
+ resolve(answer.trim());
705
+ });
706
+ });
707
+ if (!url) {
708
+ console.log(chalk.red("No URL provided. Aborting."));
709
+ process.exit(1);
710
+ }
711
+ }
712
+ console.log(chalk.gray(`URL: ${url}\n`));
713
+ // Step 3: Open browser and get page snapshot
714
+ spinner = ora("Opening browser and analyzing page...").start();
715
+ const browser = new BrowserAgent({
716
+ ...config.browser,
717
+ headless: !options.headed,
718
+ });
719
+ try {
720
+ await browser.navigate(url);
721
+ await browser.wait(2000); // Wait for page to load
722
+ const snapshot = await browser.snapshot(true);
723
+ const pageTitle = await browser.getTitle();
724
+ spinner.succeed("Page analyzed");
725
+ // Step 4: Generate test steps using AI
726
+ spinner = ora("Generating test steps...").start();
727
+ const generationResponse = await generateText({
728
+ model: getModelFromConfig(config.llm),
729
+ system: `You are a test automation expert. Generate clear, actionable test steps for a .flow file.
730
+
731
+ Rules:
732
+ - Each step should be a single action or verification
733
+ - Use natural language that describes WHAT to do, not HOW
734
+ - Include [Optional] prefix for steps that might not always apply (popups, banners)
735
+ - Include verification steps to confirm actions worked
736
+ - Use "If X appears, do Y" for conditional handling
737
+ - Keep steps concise but clear
738
+ - Start with navigation to the URL
739
+ - Generate a suitable filename (lowercase, hyphenated, no extension)
740
+
741
+ Output format:
742
+ FILENAME: suggested-name
743
+ STEPS:
744
+ Go to <url>
745
+ Step 2 here
746
+ Step 3 here
747
+ ...`,
748
+ prompt: `Generate test steps for this request:
749
+
750
+ "${prompt}"
751
+
752
+ Target URL: ${url}
753
+ Page Title: ${pageTitle}
754
+
755
+ Current page structure:
756
+ ${snapshot}
757
+
758
+ Generate practical test steps that accomplish the user's goal.`,
759
+ maxTokens: 1500,
760
+ });
761
+ spinner.succeed("Test steps generated");
762
+ // Parse the response
763
+ const responseText = generationResponse.text;
764
+ const filenameMatch = responseText.match(/FILENAME:\s*(.+)/i);
765
+ const stepsMatch = responseText.match(/STEPS:\s*([\s\S]+)/i);
766
+ let filename = filenameMatch
767
+ ? filenameMatch[1]
768
+ .trim()
769
+ .replace(/[^a-z0-9-]/gi, "-")
770
+ .toLowerCase()
771
+ : "generated-test";
772
+ if (!filename.endsWith(".flow")) {
773
+ filename += ".flow";
774
+ }
775
+ const steps = stepsMatch
776
+ ? stepsMatch[1].trim()
777
+ : responseText.replace(/FILENAME:.+/i, "").trim();
778
+ // Close browser
779
+ await browser.close();
780
+ // Step 5: Show generated steps and confirm
781
+ console.log(chalk.blue("\n━━━ Generated Flow ━━━\n"));
782
+ console.log(chalk.white(steps));
783
+ console.log(chalk.blue("\n━━━━━━━━━━━━━━━━━━━━━━\n"));
784
+ const rl = readline.createInterface({
785
+ input: process.stdin,
786
+ output: process.stdout,
787
+ });
788
+ const confirm = await new Promise((resolve) => {
789
+ rl.question(chalk.cyan(`Save as ${options.dir}/${filename}? (Y/n/edit): `), (answer) => {
790
+ rl.close();
791
+ resolve(answer.trim().toLowerCase());
792
+ });
793
+ });
794
+ if (confirm === "n" || confirm === "no") {
795
+ console.log(chalk.yellow("Cancelled."));
796
+ return;
797
+ }
798
+ if (confirm === "e" || confirm === "edit") {
799
+ // Let user edit the filename
800
+ const rl2 = readline.createInterface({
801
+ input: process.stdin,
802
+ output: process.stdout,
803
+ });
804
+ filename = await new Promise((resolve) => {
805
+ rl2.question(chalk.cyan("Filename: "), (answer) => {
806
+ rl2.close();
807
+ const name = answer.trim() || filename;
808
+ resolve(name.endsWith(".flow") ? name : name + ".flow");
809
+ });
810
+ });
811
+ }
812
+ // Step 6: Save the file
813
+ const dir = options.dir;
814
+ if (!fs.existsSync(dir)) {
815
+ fs.mkdirSync(dir, { recursive: true });
816
+ }
817
+ const filepath = path.join(dir, filename);
818
+ const content = `# ${filename
819
+ .replace(".flow", "")
820
+ .replace(/-/g, " ")}\n# Generated from: ${prompt}\n\n${steps}\n`;
821
+ fs.writeFileSync(filepath, content);
822
+ console.log(chalk.green(`\n✓ Saved: ${filepath}`));
823
+ console.log(chalk.gray(`\nRun with: slapify run ${filepath}`));
824
+ }
825
+ catch (error) {
826
+ spinner.fail("Error");
827
+ await browser.close();
828
+ console.error(chalk.red(`Error: ${error.message}`));
829
+ process.exit(1);
830
+ }
831
+ });
832
+ // Fix command - analyze and fix failing tests
833
+ program
834
+ .command("fix <file>")
835
+ .description("Analyze a failing test and suggest/apply fixes")
836
+ .option("--auto", "Automatically apply suggested fixes without confirmation")
837
+ .option("--headed", "Run browser in headed mode for debugging")
838
+ .action(async (file, options) => {
839
+ const configDir = getConfigDir();
840
+ if (!configDir) {
841
+ console.log(chalk.red('No .slapify directory found. Run "slapify init" first.'));
842
+ process.exit(1);
843
+ }
844
+ if (!fs.existsSync(file)) {
845
+ console.log(chalk.red(`File not found: ${file}`));
846
+ process.exit(1);
847
+ }
848
+ const config = loadConfig(configDir);
849
+ const credentials = loadCredentials(configDir);
850
+ if (options.headed) {
851
+ config.browser = { ...config.browser, headless: false };
852
+ }
853
+ // Enable screenshots for diagnosis
854
+ config.report = { ...config.report, screenshots: true };
855
+ const readline = await import("readline");
856
+ let spinner = ora("Running test to identify failures...").start();
857
+ try {
858
+ // Step 1: Run the test to see what fails
859
+ const flow = parseFlowFile(file);
860
+ const runner = new TestRunner(config, credentials);
861
+ const failedSteps = [];
862
+ const result = await runner.runFlow(flow, (stepResult) => {
863
+ if (stepResult.status === "failed" && stepResult.error) {
864
+ failedSteps.push({
865
+ step: stepResult.step.text,
866
+ error: stepResult.error,
867
+ line: stepResult.step.line,
868
+ screenshot: stepResult.screenshot,
869
+ });
870
+ }
871
+ });
872
+ if (result.status === "passed") {
873
+ spinner.succeed("Test passed! No fixes needed.");
874
+ return;
875
+ }
876
+ spinner.info(`Found ${failedSteps.length} failing step(s)`);
877
+ if (failedSteps.length === 0) {
878
+ console.log(chalk.yellow("No specific step failures to fix."));
879
+ return;
880
+ }
881
+ // Step 2: Read the original flow file
882
+ const originalContent = fs.readFileSync(file, "utf-8");
883
+ const lines = originalContent.split("\n");
884
+ // Step 3: Use AI to analyze and suggest fixes
885
+ spinner = ora("Analyzing failures and generating fixes...").start();
886
+ const failureDetails = failedSteps
887
+ .map((f) => `Line ${f.line}: "${f.step}"\n Error: ${f.error}`)
888
+ .join("\n\n");
889
+ const analysisResponse = await generateText({
890
+ model: getModelFromConfig(config.llm),
891
+ system: `You are a test automation expert. Analyze failing test steps and suggest fixes.
892
+
893
+ Original flow file:
894
+ \`\`\`
895
+ ${originalContent}
896
+ \`\`\`
897
+
898
+ Failing steps:
899
+ ${failureDetails}
900
+
901
+ Based on the errors, suggest fixes for the flow file. Common issues and fixes:
902
+ 1. Element not found → Try more descriptive text, add wait, or make step optional
903
+ 2. Timeout → Add explicit wait or increase timeout
904
+ 3. Navigation error → Add wait after navigation, or split into smaller steps
905
+ 4. Element obscured → Add step to close popup/modal first
906
+ 5. Stale element → Add wait for page to stabilize
907
+
908
+ Respond with JSON:
909
+ {
910
+ "analysis": "Brief explanation of what's wrong",
911
+ "fixes": [
912
+ {
913
+ "line": 5,
914
+ "original": "Click the submit button",
915
+ "fixed": "Click the Submit button",
916
+ "reason": "Button text is capitalized"
917
+ }
918
+ ],
919
+ "additions": [
920
+ {
921
+ "afterLine": 4,
922
+ "step": "[Optional] Wait for page to load",
923
+ "reason": "Page might still be loading"
924
+ }
925
+ ]
926
+ }`,
927
+ prompt: "Analyze the failures and suggest specific fixes.",
928
+ maxTokens: 1500,
929
+ });
930
+ spinner.succeed("Analysis complete");
931
+ // Parse the response
932
+ const jsonMatch = analysisResponse.text.match(/\{[\s\S]*\}/);
933
+ if (!jsonMatch) {
934
+ console.log(chalk.red("Could not parse AI response"));
935
+ return;
936
+ }
937
+ const suggestions = JSON.parse(jsonMatch[0]);
938
+ // Step 4: Display suggestions
939
+ console.log(chalk.blue("\n━━━ Analysis ━━━\n"));
940
+ console.log(chalk.white(suggestions.analysis));
941
+ if (suggestions.fixes?.length > 0 || suggestions.additions?.length > 0) {
942
+ console.log(chalk.blue("\n━━━ Suggested Fixes ━━━\n"));
943
+ if (suggestions.fixes?.length > 0) {
944
+ for (const fix of suggestions.fixes) {
945
+ console.log(chalk.yellow(`Line ${fix.line}:`));
946
+ console.log(chalk.red(` - ${fix.original}`));
947
+ console.log(chalk.green(` + ${fix.fixed}`));
948
+ console.log(chalk.gray(` Reason: ${fix.reason}\n`));
949
+ }
950
+ }
951
+ if (suggestions.additions?.length > 0) {
952
+ console.log(chalk.yellow("New steps to add:"));
953
+ for (const add of suggestions.additions) {
954
+ console.log(chalk.green(` + After line ${add.afterLine}: ${add.step}`));
955
+ console.log(chalk.gray(` Reason: ${add.reason}\n`));
956
+ }
957
+ }
958
+ // Step 5: Apply fixes
959
+ let shouldApply = options.auto;
960
+ if (!shouldApply) {
961
+ const rl = readline.createInterface({
962
+ input: process.stdin,
963
+ output: process.stdout,
964
+ });
965
+ const answer = await new Promise((resolve) => {
966
+ rl.question(chalk.cyan("Apply these fixes? (y/N): "), (answer) => {
967
+ rl.close();
968
+ resolve(answer.trim().toLowerCase());
969
+ });
970
+ });
971
+ shouldApply = answer === "y" || answer === "yes";
972
+ }
973
+ if (shouldApply) {
974
+ // Apply fixes to lines
975
+ let newLines = [...lines];
976
+ const lineOffsets = new Map(); // Track line number changes
977
+ // Apply modifications first
978
+ if (suggestions.fixes) {
979
+ for (const fix of suggestions.fixes) {
980
+ const lineIdx = fix.line - 1;
981
+ if (lineIdx >= 0 && lineIdx < newLines.length) {
982
+ newLines[lineIdx] = fix.fixed;
983
+ }
984
+ }
985
+ }
986
+ // Apply additions (in reverse order to maintain line numbers)
987
+ if (suggestions.additions) {
988
+ const sortedAdditions = [...suggestions.additions].sort((a, b) => b.afterLine - a.afterLine);
989
+ for (const add of sortedAdditions) {
990
+ const afterIdx = add.afterLine;
991
+ newLines.splice(afterIdx, 0, add.step);
992
+ }
993
+ }
994
+ // Write the fixed file
995
+ const newContent = newLines.join("\n");
996
+ // Backup original
997
+ const backupPath = file + ".backup";
998
+ fs.writeFileSync(backupPath, originalContent);
999
+ // Write fixed version
1000
+ fs.writeFileSync(file, newContent);
1001
+ console.log(chalk.green(`\n✓ Fixes applied to ${file}`));
1002
+ console.log(chalk.gray(` Backup saved to ${backupPath}`));
1003
+ console.log(chalk.gray(`\nRun again with: slapify run ${file}`));
1004
+ }
1005
+ else {
1006
+ console.log(chalk.yellow("No changes made."));
1007
+ }
1008
+ }
1009
+ else {
1010
+ console.log(chalk.yellow("\nNo automatic fixes suggested."));
1011
+ console.log(chalk.gray("The failures may require manual investigation."));
1012
+ }
1013
+ }
1014
+ catch (error) {
1015
+ spinner.fail("Error");
1016
+ console.error(chalk.red(`Error: ${error.message}`));
1017
+ process.exit(1);
1018
+ }
1019
+ });
1020
+ // Validate command
1021
+ program
1022
+ .command("validate [files...]")
1023
+ .description("Validate flow files for syntax issues")
1024
+ .action(async (files) => {
1025
+ let flowFiles = [];
1026
+ if (files.length === 0) {
1027
+ const testsDir = path.join(process.cwd(), "tests");
1028
+ if (fs.existsSync(testsDir)) {
1029
+ flowFiles = await findFlowFiles(testsDir);
1030
+ }
1031
+ }
1032
+ else {
1033
+ flowFiles = files;
1034
+ }
1035
+ if (flowFiles.length === 0) {
1036
+ console.log(chalk.yellow("No .flow files found."));
1037
+ return;
1038
+ }
1039
+ let hasWarnings = false;
1040
+ for (const file of flowFiles) {
1041
+ try {
1042
+ const flow = parseFlowFile(file);
1043
+ const warnings = validateFlowFile(flow);
1044
+ const summary = getFlowSummary(flow);
1045
+ if (warnings.length > 0) {
1046
+ hasWarnings = true;
1047
+ console.log(chalk.yellow(`⚠️ ${file}`));
1048
+ for (const warning of warnings) {
1049
+ console.log(chalk.yellow(` ${warning}`));
1050
+ }
1051
+ }
1052
+ else {
1053
+ console.log(chalk.green(`✅ ${file}`));
1054
+ console.log(chalk.gray(` ${summary.totalSteps} steps (${summary.requiredSteps} required, ${summary.optionalSteps} optional)`));
1055
+ }
1056
+ }
1057
+ catch (error) {
1058
+ console.log(chalk.red(`❌ ${file}`));
1059
+ console.log(chalk.red(` ${error.message}`));
1060
+ hasWarnings = true;
1061
+ }
1062
+ }
1063
+ if (hasWarnings) {
1064
+ process.exit(1);
1065
+ }
1066
+ });
1067
+ // List command
1068
+ program
1069
+ .command("list")
1070
+ .description("List all flow files")
1071
+ .action(async () => {
1072
+ const testsDir = path.join(process.cwd(), "tests");
1073
+ if (!fs.existsSync(testsDir)) {
1074
+ console.log(chalk.yellow("No tests directory found."));
1075
+ return;
1076
+ }
1077
+ const flowFiles = await findFlowFiles(testsDir);
1078
+ if (flowFiles.length === 0) {
1079
+ console.log(chalk.yellow("No .flow files found."));
1080
+ return;
1081
+ }
1082
+ console.log(chalk.blue(`\nFound ${flowFiles.length} flow file(s):\n`));
1083
+ for (const file of flowFiles) {
1084
+ const flow = parseFlowFile(file);
1085
+ const summary = getFlowSummary(flow);
1086
+ const relativePath = path.relative(process.cwd(), file);
1087
+ console.log(` ${chalk.white(relativePath)}`);
1088
+ console.log(chalk.gray(` ${summary.totalSteps} steps (${summary.requiredSteps} required, ${summary.optionalSteps} optional)`));
1089
+ }
1090
+ console.log("");
1091
+ });
1092
+ // Credentials command
1093
+ program
1094
+ .command("credentials")
1095
+ .description("List configured credential profiles")
1096
+ .action(() => {
1097
+ const configDir = getConfigDir();
1098
+ if (!configDir) {
1099
+ console.log(chalk.red('No .slapify directory found. Run "slapify init" first.'));
1100
+ process.exit(1);
1101
+ }
1102
+ const credentials = loadCredentials(configDir);
1103
+ const profiles = Object.keys(credentials.profiles);
1104
+ if (profiles.length === 0) {
1105
+ console.log(chalk.yellow("No credential profiles configured."));
1106
+ console.log(chalk.gray("Edit .slapify/credentials.yaml to add profiles."));
1107
+ return;
1108
+ }
1109
+ console.log(chalk.blue(`\nConfigured credential profiles:\n`));
1110
+ for (const name of profiles) {
1111
+ const profile = credentials.profiles[name];
1112
+ console.log(` ${chalk.white(name)} (${profile.type})`);
1113
+ if (profile.username) {
1114
+ console.log(chalk.gray(` username: ${profile.username}`));
1115
+ }
1116
+ if (profile.email) {
1117
+ console.log(chalk.gray(` email: ${profile.email}`));
1118
+ }
1119
+ if (profile.totp_secret) {
1120
+ console.log(chalk.gray(` 2FA: TOTP configured`));
1121
+ }
1122
+ if (profile.fixed_otp) {
1123
+ console.log(chalk.gray(` 2FA: Fixed OTP configured`));
1124
+ }
1125
+ }
1126
+ console.log("");
1127
+ });
1128
+ // Interactive mode
1129
+ program
1130
+ .command("interactive [url]")
1131
+ .alias("i")
1132
+ .description("Run steps interactively")
1133
+ .option("--headed", "Run browser in headed mode")
1134
+ .action(async (url, options) => {
1135
+ console.log(chalk.blue("\n🧪 Slapify Interactive Mode"));
1136
+ console.log(chalk.gray('Type test steps and press Enter to execute. Type "exit" to quit.\n'));
1137
+ const configDir = getConfigDir();
1138
+ if (!configDir) {
1139
+ console.log(chalk.red('No .slapify directory found. Run "slapify init" first.'));
1140
+ process.exit(1);
1141
+ }
1142
+ const config = loadConfig(configDir);
1143
+ const credentials = loadCredentials(configDir);
1144
+ if (options.headed) {
1145
+ config.browser = { ...config.browser, headless: false };
1146
+ }
1147
+ const runner = new TestRunner(config, credentials);
1148
+ if (url) {
1149
+ console.log(chalk.gray(`Navigating to ${url}...`));
1150
+ // Would navigate here
1151
+ }
1152
+ // Simple readline interface
1153
+ const readline = await import("readline");
1154
+ const rl = readline.createInterface({
1155
+ input: process.stdin,
1156
+ output: process.stdout,
1157
+ });
1158
+ const prompt = () => {
1159
+ rl.question(chalk.cyan("> "), async (input) => {
1160
+ const trimmed = input.trim();
1161
+ if (trimmed.toLowerCase() === "exit" ||
1162
+ trimmed.toLowerCase() === "quit") {
1163
+ console.log(chalk.gray("\nGoodbye!"));
1164
+ rl.close();
1165
+ process.exit(0);
1166
+ }
1167
+ if (!trimmed) {
1168
+ prompt();
1169
+ return;
1170
+ }
1171
+ // Execute the step
1172
+ console.log(chalk.gray(`Executing: ${trimmed}`));
1173
+ // Would execute step here
1174
+ console.log(chalk.green("✓ Done"));
1175
+ prompt();
1176
+ });
1177
+ };
1178
+ prompt();
1179
+ });
1180
+ program.parse();
1181
+ //# sourceMappingURL=cli.js.map