sandal-db 1.0.2 → 1.0.3

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.
Files changed (2) hide show
  1. package/dist/cli.js +155 -91
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import fs4 from "node:fs";
5
5
  import { Command } from "commander";
6
6
  import chalk4 from "chalk";
7
7
  import ora2 from "ora";
8
- import { input as input2, select, confirm as confirm2 } from "@inquirer/prompts";
8
+ import { input, select, confirm } from "@inquirer/prompts";
9
9
 
10
10
  // src/config/index.ts
11
11
  import fs from "node:fs";
@@ -965,7 +965,7 @@ function createChatModel(options) {
965
965
  }
966
966
 
967
967
  // src/repl.ts
968
- import readline from "node:readline";
968
+ import readline2 from "node:readline";
969
969
  import fs3 from "node:fs";
970
970
  import chalk3 from "chalk";
971
971
  import boxen2 from "boxen";
@@ -1382,10 +1382,42 @@ function classifyMongoQuery(rawText, query, rowThreshold, options) {
1382
1382
  }
1383
1383
 
1384
1384
  // src/safety/confirm.ts
1385
+ import readline from "node:readline";
1385
1386
  import chalk from "chalk";
1386
1387
  import boxen from "boxen";
1387
- import { confirm, input } from "@inquirer/prompts";
1388
- async function requestUserConfirmation(query, safety, affectedRows) {
1388
+ async function defaultAsk(promptText) {
1389
+ process.stdin.resume();
1390
+ const rl = readline.createInterface({
1391
+ input: process.stdin,
1392
+ output: process.stdout
1393
+ });
1394
+ return new Promise((resolve) => {
1395
+ rl.question(promptText, (answer) => {
1396
+ rl.close();
1397
+ process.stdin.resume();
1398
+ resolve(answer);
1399
+ });
1400
+ });
1401
+ }
1402
+ async function promptConfirm(message, defaultValue, ask = defaultAsk) {
1403
+ const suffix = defaultValue ? chalk.gray(" (Y/n): ") : chalk.gray(" (y/N): ");
1404
+ const response = (await ask(`${message}${suffix}`)).trim().toLowerCase();
1405
+ if (!response) {
1406
+ return defaultValue;
1407
+ }
1408
+ if (["y", "yes", "true", "1"].includes(response)) {
1409
+ return true;
1410
+ }
1411
+ if (["n", "no", "false", "0"].includes(response)) {
1412
+ return false;
1413
+ }
1414
+ return defaultValue;
1415
+ }
1416
+ async function promptInput(message, ask = defaultAsk) {
1417
+ const response = await ask(message);
1418
+ return response.trim();
1419
+ }
1420
+ async function requestUserConfirmation(query, safety, affectedRows, ask = defaultAsk) {
1389
1421
  const queryDisplay = (query.sql || query.rawDisplay || "").trim();
1390
1422
  if (safety.isFullWipe) {
1391
1423
  const boxContent = [
@@ -1404,9 +1436,10 @@ async function requestUserConfirmation(query, safety, affectedRows) {
1404
1436
  margin: { top: 1, bottom: 1 }
1405
1437
  })
1406
1438
  );
1407
- const typed = await input({
1408
- message: chalk.red.bold(`Type "${safety.literalWord || "DROP DATABASE"}" to confirm full wipe:`)
1409
- });
1439
+ const typed = await promptInput(
1440
+ chalk.red.bold(`Type "${safety.literalWord || "DROP DATABASE"}" to confirm full wipe: `),
1441
+ ask
1442
+ );
1410
1443
  if (typed.trim() === safety.literalWord) {
1411
1444
  return { confirmed: true };
1412
1445
  }
@@ -1443,11 +1476,12 @@ async function requestUserConfirmation(query, safety, affectedRows) {
1443
1476
  })
1444
1477
  );
1445
1478
  if (safety.requiresLiteralWord && safety.literalWord) {
1446
- const typed = await input({
1447
- message: chalk.hex("#FFA500").bold(
1448
- `Destructive operation with no filter. Type "${safety.literalWord}" to confirm:`
1449
- )
1450
- });
1479
+ const typed = await promptInput(
1480
+ chalk.hex("#FFA500").bold(
1481
+ `Destructive operation with no filter. Type "${safety.literalWord}" to confirm: `
1482
+ ),
1483
+ ask
1484
+ );
1451
1485
  if (typed.trim() === safety.literalWord) {
1452
1486
  return { confirmed: true };
1453
1487
  }
@@ -1456,10 +1490,11 @@ async function requestUserConfirmation(query, safety, affectedRows) {
1456
1490
  reason: `Confirmation word mismatch (expected "${safety.literalWord}"). Operation cancelled.`
1457
1491
  };
1458
1492
  }
1459
- const answer2 = await confirm({
1460
- message: chalk.hex("#FFA500").bold("Proceed with dangerous operation?"),
1461
- default: false
1462
- });
1493
+ const answer2 = await promptConfirm(
1494
+ chalk.hex("#FFA500").bold("Proceed with dangerous operation?"),
1495
+ false,
1496
+ ask
1497
+ );
1463
1498
  return { confirmed: answer2 };
1464
1499
  }
1465
1500
  if (safety.isStructural) {
@@ -1491,10 +1526,11 @@ async function requestUserConfirmation(query, safety, affectedRows) {
1491
1526
  margin: { top: 1, bottom: 1 }
1492
1527
  })
1493
1528
  );
1494
- const answer2 = await confirm({
1495
- message: chalk.cyan.bold("Apply this structural change?"),
1496
- default: true
1497
- });
1529
+ const answer2 = await promptConfirm(
1530
+ chalk.cyan.bold("Apply this structural change?"),
1531
+ true,
1532
+ ask
1533
+ );
1498
1534
  return { confirmed: answer2 };
1499
1535
  }
1500
1536
  if (safety.category === "write") {
@@ -1514,10 +1550,11 @@ async function requestUserConfirmation(query, safety, affectedRows) {
1514
1550
  margin: { top: 1, bottom: 1 }
1515
1551
  })
1516
1552
  );
1517
- const answer2 = await confirm({
1518
- message: chalk.magenta.bold("Execute write query?"),
1519
- default: false
1520
- });
1553
+ const answer2 = await promptConfirm(
1554
+ chalk.magenta.bold("Execute write query?"),
1555
+ false,
1556
+ ask
1557
+ );
1521
1558
  return { confirmed: answer2 };
1522
1559
  }
1523
1560
  const lines = [
@@ -1533,10 +1570,11 @@ async function requestUserConfirmation(query, safety, affectedRows) {
1533
1570
  margin: { top: 1, bottom: 1 }
1534
1571
  })
1535
1572
  );
1536
- const answer = await confirm({
1537
- message: chalk.green.bold("Execute read query?"),
1538
- default: true
1539
- });
1573
+ const answer = await promptConfirm(
1574
+ chalk.green.bold("Execute read query?"),
1575
+ true,
1576
+ ask
1577
+ );
1540
1578
  return { confirmed: answer };
1541
1579
  }
1542
1580
 
@@ -1668,8 +1706,8 @@ var OBVIOUS_DATABASE_PATTERNS = [
1668
1706
  /\b(?:how many (?:users|rows|records|orders|items|products|documents))\b/i,
1669
1707
  /\b(?:database|postgres|mongodb|mongo|pg_)\b/i
1670
1708
  ];
1671
- async function classifyIntent(input3, model, historyContext) {
1672
- const trimmed = input3.trim();
1709
+ async function classifyIntent(input2, model, historyContext) {
1710
+ const trimmed = input2.trim();
1673
1711
  for (const pattern of OBVIOUS_OUT_OF_SCOPE_PATTERNS) {
1674
1712
  if (pattern.test(trimmed)) {
1675
1713
  return {
@@ -1922,7 +1960,8 @@ Do not wrap with markdown or code fences.`;
1922
1960
  if (!state.generatedQuery || !state.safety) {
1923
1961
  return { confirmed: false, cancelReason: "Missing query or safety classification." };
1924
1962
  }
1925
- const result = await requestUserConfirmation(
1963
+ const confirmHandler = config.confirmFn || requestUserConfirmation;
1964
+ const result = await confirmHandler(
1926
1965
  state.generatedQuery,
1927
1966
  state.safety,
1928
1967
  state.affectedRowCount ?? null
@@ -2058,9 +2097,9 @@ function createMarkdownRenderer(options = {}) {
2058
2097
  codespan: chalk2.yellow,
2059
2098
  code: chalk2.yellow,
2060
2099
  blockquote: chalk2.gray.italic,
2061
- hr: (input3) => {
2100
+ hr: (input2) => {
2062
2101
  const w = Math.min(options.width ?? (process.stdout.columns || 80), 80);
2063
- return chalk2.gray(input3 && input3.trim() ? input3 : "\u2500".repeat(w));
2102
+ return chalk2.gray(input2 && input2.trim() ? input2 : "\u2500".repeat(w));
2064
2103
  },
2065
2104
  table: chalk2.reset,
2066
2105
  tableOptions: {
@@ -2312,6 +2351,8 @@ var ReplSession = class {
2312
2351
  agent;
2313
2352
  memoryManager;
2314
2353
  classifierOptions;
2354
+ rl;
2355
+ currentSpinner;
2315
2356
  constructor(options) {
2316
2357
  this.adapter = options.adapter;
2317
2358
  this.model = options.model;
@@ -2327,11 +2368,7 @@ var ReplSession = class {
2327
2368
  allowFullWipe: this.allowFullWipe,
2328
2369
  rowThresholdForDangerousUpdate: this.rowThreshold
2329
2370
  };
2330
- this.agent = createDatabaseAgent({
2331
- adapter: this.adapter,
2332
- model: this.model,
2333
- classifierOptions: this.classifierOptions
2334
- });
2371
+ this.initAgent();
2335
2372
  this.memoryManager = new ChatMemoryManager();
2336
2373
  const initialSession = this.memoryManager.createSession("New Chat", this.adapter.connectionUrl);
2337
2374
  this.sessionId = initialSession.id;
@@ -2339,23 +2376,57 @@ var ReplSession = class {
2339
2376
  addSavedConnection(this.adapter.connectionUrl);
2340
2377
  }
2341
2378
  }
2379
+ initAgent() {
2380
+ this.agent = createDatabaseAgent({
2381
+ adapter: this.adapter,
2382
+ model: this.model,
2383
+ classifierOptions: this.classifierOptions,
2384
+ confirmFn: async (query, safety, affectedRows) => {
2385
+ if (this.currentSpinner && this.currentSpinner.isSpinning) {
2386
+ this.currentSpinner.stop();
2387
+ }
2388
+ const confirmResult = await requestUserConfirmation(
2389
+ query,
2390
+ safety,
2391
+ affectedRows,
2392
+ this.askQuestion.bind(this)
2393
+ );
2394
+ if (confirmResult.confirmed && this.currentSpinner) {
2395
+ this.currentSpinner.text = chalk3.cyan("Executing query and analyzing...");
2396
+ this.currentSpinner.start();
2397
+ }
2398
+ return confirmResult;
2399
+ }
2400
+ });
2401
+ }
2402
+ askQuestion(query) {
2403
+ process.stdin.resume();
2404
+ if (!this.rl || this.rl.closed) {
2405
+ this.rl = readline2.createInterface({
2406
+ input: process.stdin,
2407
+ output: process.stdout,
2408
+ terminal: true
2409
+ });
2410
+ }
2411
+ return new Promise((resolve) => this.rl.question(query, resolve));
2412
+ }
2342
2413
  getPromptText() {
2343
2414
  return chalk3.cyan(`sandal [${this.adapter.type}]> `);
2344
2415
  }
2345
2416
  async start() {
2346
2417
  this.setupSignalHandlers();
2347
2418
  this.printWelcomeBanner();
2348
- const rl = readline.createInterface({
2419
+ process.stdin.resume();
2420
+ this.rl = readline2.createInterface({
2349
2421
  input: process.stdin,
2350
2422
  output: process.stdout,
2351
2423
  terminal: true
2352
2424
  });
2353
- const askQuestion = (query) => {
2354
- return new Promise((resolve) => rl.question(query, resolve));
2355
- };
2425
+ const askQuestion = this.askQuestion.bind(this);
2356
2426
  while (this.isRunning) {
2357
- const input3 = await askQuestion(this.getPromptText());
2358
- const trimmed = input3.trim();
2427
+ process.stdin.resume();
2428
+ const input2 = await askQuestion(this.getPromptText());
2429
+ const trimmed = input2.trim();
2359
2430
  if (!trimmed) continue;
2360
2431
  if (["exit", "quit", ".exit", ".quit"].includes(trimmed.toLowerCase())) {
2361
2432
  break;
@@ -2462,6 +2533,7 @@ ${chalk3.gray(
2462
2533
  continue;
2463
2534
  }
2464
2535
  const agentSpinner = ora(chalk3.cyan("Agent planning query...")).start();
2536
+ this.currentSpinner = agentSpinner;
2465
2537
  try {
2466
2538
  const historyMessages = this.memoryManager.toLangChainMessages(this.sessionId, 6);
2467
2539
  const result = await this.agent.invoke(
@@ -2475,7 +2547,9 @@ ${chalk3.gray(
2475
2547
  }
2476
2548
  }
2477
2549
  );
2478
- agentSpinner.stop();
2550
+ if (agentSpinner.isSpinning) {
2551
+ agentSpinner.stop();
2552
+ }
2479
2553
  if (result.queryResult && result.queryResult.success && result.queryResult.rows) {
2480
2554
  this.renderRowsTable(result.queryResult);
2481
2555
  }
@@ -2506,10 +2580,24 @@ ${chalk3.gray(
2506
2580
  );
2507
2581
  }
2508
2582
  } catch (err) {
2509
- agentSpinner.fail(chalk3.red(`Execution failed: ${err.message}`));
2583
+ if (agentSpinner.isSpinning) {
2584
+ agentSpinner.fail(chalk3.red(`Execution failed: ${err.message}`));
2585
+ } else {
2586
+ console.error(chalk3.red(`
2587
+ Execution failed: ${err.message}
2588
+ `));
2589
+ }
2590
+ } finally {
2591
+ if (agentSpinner.isSpinning) {
2592
+ agentSpinner.stop();
2593
+ }
2594
+ this.currentSpinner = void 0;
2595
+ process.stdin.resume();
2510
2596
  }
2511
2597
  }
2512
- rl.close();
2598
+ if (this.rl && !this.rl.closed) {
2599
+ this.rl.close();
2600
+ }
2513
2601
  await this.shutdown();
2514
2602
  }
2515
2603
  async handleSwitchConnection(targetUrl, ask) {
@@ -2566,11 +2654,7 @@ ${chalk3.gray(
2566
2654
  } catch {
2567
2655
  }
2568
2656
  this.adapter = newAdapter;
2569
- this.agent = createDatabaseAgent({
2570
- adapter: this.adapter,
2571
- model: this.model,
2572
- classifierOptions: this.classifierOptions
2573
- });
2657
+ this.initAgent();
2574
2658
  addSavedConnection(urlToConnect);
2575
2659
  spinner.succeed(
2576
2660
  chalk3.green(
@@ -2611,17 +2695,17 @@ Current Model: `) + chalk3.white(this.modelName) + chalk3.gray(` (${this.provide
2611
2695
  });
2612
2696
  console.log(chalk3.gray(" [O] Other (type custom model name)"));
2613
2697
  console.log(chalk3.gray(" [C] Cancel\n"));
2614
- const input3 = (await ask(chalk3.cyan("Select model [number, name, C]: "))).trim();
2615
- if (!input3 || input3.toLowerCase() === "c") return;
2616
- const num = parseInt(input3, 10);
2698
+ const input2 = (await ask(chalk3.cyan("Select model [number, name, C]: "))).trim();
2699
+ if (!input2 || input2.toLowerCase() === "c") return;
2700
+ const num = parseInt(input2, 10);
2617
2701
  if (!isNaN(num) && num >= 1 && num <= list.length) {
2618
2702
  targetModel = list[num - 1];
2619
- } else if (input3.toLowerCase() === "o") {
2703
+ } else if (input2.toLowerCase() === "o") {
2620
2704
  const custom = (await ask(chalk3.cyan("Enter model name: "))).trim();
2621
2705
  if (!custom) return;
2622
2706
  targetModel = custom;
2623
2707
  } else {
2624
- targetModel = input3;
2708
+ targetModel = input2;
2625
2709
  }
2626
2710
  }
2627
2711
  if (!this.apiKey) {
@@ -2645,11 +2729,7 @@ No API key found for ${this.provider.toUpperCase()}.`));
2645
2729
  apiKey: this.apiKey
2646
2730
  });
2647
2731
  this.modelName = targetModel;
2648
- this.agent = createDatabaseAgent({
2649
- adapter: this.adapter,
2650
- model: this.model,
2651
- classifierOptions: this.classifierOptions
2652
- });
2732
+ this.initAgent();
2653
2733
  console.log(chalk3.green(`
2654
2734
  Updated active model to: ${targetModel} [${this.provider}]
2655
2735
  `));
@@ -2706,11 +2786,7 @@ No API key found for ${p.toUpperCase()}.`));
2706
2786
  this.provider = p;
2707
2787
  this.modelName = defaultModel;
2708
2788
  this.apiKey = key;
2709
- this.agent = createDatabaseAgent({
2710
- adapter: this.adapter,
2711
- model: this.model,
2712
- classifierOptions: this.classifierOptions
2713
- });
2789
+ this.initAgent();
2714
2790
  console.log(
2715
2791
  chalk3.green(`
2716
2792
  Switched provider to ${p.toUpperCase()} with model ${defaultModel}.
@@ -2753,11 +2829,7 @@ Removed stored API key for "${this.provider}" from ~/.sandal/config.json.`));
2753
2829
  model: this.modelName,
2754
2830
  apiKey: newKey
2755
2831
  });
2756
- this.agent = createDatabaseAgent({
2757
- adapter: this.adapter,
2758
- model: this.model,
2759
- classifierOptions: this.classifierOptions
2760
- });
2832
+ this.initAgent();
2761
2833
  console.log(chalk3.green(`
2762
2834
  Updated API key for "${this.provider}" and refreshed model.
2763
2835
  `));
@@ -2794,11 +2866,7 @@ Updated API key for "${this.provider}" and refreshed model.
2794
2866
  handleNewChat() {
2795
2867
  const newSession = this.memoryManager.createSession("New Chat", this.adapter.connectionUrl);
2796
2868
  this.sessionId = newSession.id;
2797
- this.agent = createDatabaseAgent({
2798
- adapter: this.adapter,
2799
- model: this.model,
2800
- classifierOptions: this.classifierOptions
2801
- });
2869
+ this.initAgent();
2802
2870
  console.log(chalk3.green(`
2803
2871
  Started fresh chat session: ${this.sessionId}`));
2804
2872
  console.log(chalk3.gray("Chat memory is clean for this new session.\n"));
@@ -2829,11 +2897,7 @@ Could not load chat session "${targetId}".
2829
2897
  return;
2830
2898
  }
2831
2899
  this.sessionId = session.id;
2832
- this.agent = createDatabaseAgent({
2833
- adapter: this.adapter,
2834
- model: this.model,
2835
- classifierOptions: this.classifierOptions
2836
- });
2900
+ this.initAgent();
2837
2901
  console.log(
2838
2902
  chalk3.green(
2839
2903
  `
@@ -2871,11 +2935,7 @@ Chat History: "${session.title}" [${this.sessionId}]:`));
2871
2935
  }
2872
2936
  handleClearChat() {
2873
2937
  this.memoryManager.clearSession(this.sessionId);
2874
- this.agent = createDatabaseAgent({
2875
- adapter: this.adapter,
2876
- model: this.model,
2877
- classifierOptions: this.classifierOptions
2878
- });
2938
+ this.initAgent();
2879
2939
  console.log(chalk3.green(`
2880
2940
  Chat memory for session [${this.sessionId}] has been cleared.
2881
2941
  `));
@@ -3102,6 +3162,9 @@ Schema for collection: ${collection.name}`));
3102
3162
  process.on("SIGTERM", handleExit);
3103
3163
  }
3104
3164
  async shutdown() {
3165
+ if (this.rl && !this.rl.closed) {
3166
+ this.rl.close();
3167
+ }
3105
3168
  if (this.adapter.isConnected()) {
3106
3169
  try {
3107
3170
  await this.adapter.disconnect();
@@ -3351,7 +3414,7 @@ async function runCli(opts) {
3351
3414
  "\nNo database connection URL found in CLI flags, environment, or config."
3352
3415
  )
3353
3416
  );
3354
- dbUrl = await input2({
3417
+ dbUrl = await input({
3355
3418
  message: "Enter your database URL (postgres://... or mongodb://...):",
3356
3419
  validate: (val) => {
3357
3420
  try {
@@ -3362,7 +3425,7 @@ async function runCli(opts) {
3362
3425
  }
3363
3426
  }
3364
3427
  });
3365
- const saveDb = await confirm2({
3428
+ const saveDb = await confirm({
3366
3429
  message: "Save this database URL to ~/.sandal/config.json for future runs?",
3367
3430
  default: true
3368
3431
  });
@@ -3387,11 +3450,11 @@ async function runCli(opts) {
3387
3450
  { name: "Anthropic (ANTHROPIC_API_KEY)", value: "anthropic" }
3388
3451
  ]
3389
3452
  });
3390
- apiKey = await input2({
3453
+ apiKey = await input({
3391
3454
  message: `Enter your ${provider.toUpperCase()} API key:`,
3392
3455
  validate: (val) => val.trim().length > 0 ? true : "API key cannot be empty."
3393
3456
  });
3394
- const saveKey = await confirm2({
3457
+ const saveKey = await confirm({
3395
3458
  message: "Save this API key to ~/.sandal/config.json?",
3396
3459
  default: true
3397
3460
  });
@@ -3426,6 +3489,7 @@ async function runCli(opts) {
3426
3489
  model: modelName,
3427
3490
  apiKey
3428
3491
  });
3492
+ process.stdin.resume();
3429
3493
  const repl = new ReplSession({
3430
3494
  adapter,
3431
3495
  model,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandal-db",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "SANDAL - Safe Agentic Natural-language Database Access Layer. Production-grade agentic database assistant CLI using LangGraph and LLMs (Google Gemini, OpenAI, Anthropic)",
5
5
  "type": "module",
6
6
  "bin": {