sandal-db 1.0.0 → 1.0.2

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 (3) hide show
  1. package/README.md +109 -0
  2. package/dist/cli.js +1091 -107
  3. package/package.json +4 -1
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
+ import fs4 from "node:fs";
4
5
  import { Command } from "commander";
5
- import chalk3 from "chalk";
6
+ import chalk4 from "chalk";
6
7
  import ora2 from "ora";
7
8
  import { input as input2, select, confirm as confirm2 } from "@inquirer/prompts";
8
9
 
@@ -183,6 +184,69 @@ function resolveCredentials(options) {
183
184
  }
184
185
  };
185
186
  }
187
+ function removeApiKey(provider) {
188
+ const filePath = getConfigFilePath();
189
+ const dirPath = getConfigDirPath();
190
+ const cfg = readConfig();
191
+ if (provider === "all") {
192
+ delete cfg.geminiApiKey;
193
+ delete cfg.openaiApiKey;
194
+ delete cfg.anthropicApiKey;
195
+ } else if (provider === "google") {
196
+ delete cfg.geminiApiKey;
197
+ } else if (provider === "openai") {
198
+ delete cfg.openaiApiKey;
199
+ } else if (provider === "anthropic") {
200
+ delete cfg.anthropicApiKey;
201
+ }
202
+ if (!fs.existsSync(dirPath)) {
203
+ fs.mkdirSync(dirPath, { recursive: true, mode: 448 });
204
+ }
205
+ fs.writeFileSync(filePath, JSON.stringify(cfg, null, 2), {
206
+ encoding: "utf-8",
207
+ mode: 384
208
+ });
209
+ try {
210
+ fs.chmodSync(filePath, 384);
211
+ } catch {
212
+ }
213
+ }
214
+ function getSavedConnections() {
215
+ const cfg = readConfig();
216
+ const list = Array.isArray(cfg.savedConnections) ? [...cfg.savedConnections] : [];
217
+ if (cfg.dbUrl && !list.includes(cfg.dbUrl)) {
218
+ list.unshift(cfg.dbUrl);
219
+ }
220
+ return list;
221
+ }
222
+ function addSavedConnection(rawUrl) {
223
+ if (!rawUrl || typeof rawUrl !== "string") return;
224
+ const trimmed = rawUrl.trim();
225
+ if (!trimmed) return;
226
+ const current = getSavedConnections();
227
+ const filtered = current.filter((u) => u !== trimmed);
228
+ const updated = [trimmed, ...filtered].slice(0, 20);
229
+ writeConfig({
230
+ savedConnections: updated
231
+ });
232
+ }
233
+ function removeSavedConnection(target) {
234
+ const current = getSavedConnections();
235
+ let updated;
236
+ if (typeof target === "number") {
237
+ const idx = target - 1;
238
+ if (idx < 0 || idx >= current.length) return false;
239
+ updated = current.filter((_, i) => i !== idx);
240
+ } else {
241
+ const normalized = target.trim();
242
+ if (!current.includes(normalized)) return false;
243
+ updated = current.filter((u) => u !== normalized);
244
+ }
245
+ writeConfig({
246
+ savedConnections: updated
247
+ });
248
+ return true;
249
+ }
186
250
 
187
251
  // src/db/postgres.ts
188
252
  import pg from "pg";
@@ -902,7 +966,8 @@ function createChatModel(options) {
902
966
 
903
967
  // src/repl.ts
904
968
  import readline from "node:readline";
905
- import chalk2 from "chalk";
969
+ import fs3 from "node:fs";
970
+ import chalk3 from "chalk";
906
971
  import boxen2 from "boxen";
907
972
  import ora from "ora";
908
973
  import Table from "cli-table3";
@@ -1603,7 +1668,7 @@ var OBVIOUS_DATABASE_PATTERNS = [
1603
1668
  /\b(?:how many (?:users|rows|records|orders|items|products|documents))\b/i,
1604
1669
  /\b(?:database|postgres|mongodb|mongo|pg_)\b/i
1605
1670
  ];
1606
- async function classifyIntent(input3, model) {
1671
+ async function classifyIntent(input3, model, historyContext) {
1607
1672
  const trimmed = input3.trim();
1608
1673
  for (const pattern of OBVIOUS_OUT_OF_SCOPE_PATTERNS) {
1609
1674
  if (pattern.test(trimmed)) {
@@ -1623,12 +1688,23 @@ async function classifyIntent(input3, model) {
1623
1688
  if (/^(?:tables|collections|schema|indexes|views|stats|count|info)$/i.test(trimmed)) {
1624
1689
  return { isDatabaseTask: true };
1625
1690
  }
1691
+ if (historyContext && /^(?:now\s+|and\s+|also\s+|then\s+)?(?:count|sort|filter|show|order|group|limit|delete|update|find|export|why|how many|what about|which ones?|where)\b/i.test(trimmed)) {
1692
+ return { isDatabaseTask: true };
1693
+ }
1626
1694
  if (!model) {
1627
1695
  return { isDatabaseTask: true };
1628
1696
  }
1629
1697
  try {
1698
+ const promptText = historyContext ? `${INTENT_CLASSIFICATION_PROMPT}
1699
+
1700
+ Recent conversation context:
1701
+ ${historyContext}
1702
+
1703
+ Current user request:
1704
+ ${trimmed}` : `${INTENT_CLASSIFICATION_PROMPT}
1705
+ ${trimmed}`;
1630
1706
  const response = await model.invoke([
1631
- new HumanMessage(INTENT_CLASSIFICATION_PROMPT + "\n" + trimmed)
1707
+ new HumanMessage(promptText)
1632
1708
  ]);
1633
1709
  const content = typeof response.content === "string" ? response.content : JSON.stringify(response.content);
1634
1710
  const jsonMatch = content.match(/\{[\s\S]*?\}/);
@@ -1738,6 +1814,7 @@ function createDatabaseAgent(config) {
1738
1814
  };
1739
1815
  }
1740
1816
  async function identifyTargetsNode(state) {
1817
+ const historyMessages = state.messages.slice(-4);
1741
1818
  const prompt = `Based on this user request: "${state.userInput}"
1742
1819
  And the available database schema:
1743
1820
  ${state.schemaSummary}
@@ -1745,7 +1822,10 @@ ${state.schemaSummary}
1745
1822
  List the relevant table names (PostgreSQL) or collection names (MongoDB) needed to fulfill the request.
1746
1823
  Respond with a JSON array of string names only, e.g. ["users", "orders"]. Do not include markdown codeblocks or extra text.`;
1747
1824
  try {
1748
- const response = await model.invoke([new HumanMessage2(prompt)]);
1825
+ const response = await model.invoke([
1826
+ ...historyMessages,
1827
+ new HumanMessage2(prompt)
1828
+ ]);
1749
1829
  const content = typeof response.content === "string" ? response.content.trim() : "";
1750
1830
  const match = content.match(/\[[\s\S]*?\]/);
1751
1831
  const targets = match ? JSON.parse(match[0]) : [];
@@ -1938,15 +2018,21 @@ Ground your response strictly in the query results above. Never hallucinate rows
1938
2018
  try {
1939
2019
  const response = await model.invoke([new HumanMessage2(prompt)]);
1940
2020
  const conclusion = typeof response.content === "string" ? response.content : JSON.stringify(response.content);
2021
+ const queryStr = state.generatedQuery?.sql || state.generatedQuery?.rawDisplay;
2022
+ const historyAiText = queryStr ? `[Executed Query: ${queryStr}]
2023
+ ${conclusion}` : conclusion;
1941
2024
  return {
1942
2025
  conclusion,
1943
- messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
2026
+ messages: [new HumanMessage2(state.userInput), new AIMessage(historyAiText)]
1944
2027
  };
1945
2028
  } catch (err) {
1946
2029
  const conclusion = `Query executed successfully (${res.rowCount ?? 0} rows, ${res.durationMs}ms).`;
2030
+ const queryStr = state.generatedQuery?.sql || state.generatedQuery?.rawDisplay;
2031
+ const historyAiText = queryStr ? `[Executed Query: ${queryStr}]
2032
+ ${conclusion}` : conclusion;
1947
2033
  return {
1948
2034
  conclusion,
1949
- messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
2035
+ messages: [new HumanMessage2(state.userInput), new AIMessage(historyAiText)]
1950
2036
  };
1951
2037
  }
1952
2038
  }
@@ -1961,27 +2047,282 @@ Ground your response strictly in the query results above. Never hallucinate rows
1961
2047
  return app;
1962
2048
  }
1963
2049
 
2050
+ // src/markdown.ts
2051
+ import { Marked } from "marked";
2052
+ import { markedTerminal } from "marked-terminal";
2053
+ import chalk2 from "chalk";
2054
+ function createMarkdownRenderer(options = {}) {
2055
+ const terminalOptions = {
2056
+ heading: chalk2.bold.cyan,
2057
+ firstHeading: chalk2.bold.cyan.underline,
2058
+ codespan: chalk2.yellow,
2059
+ code: chalk2.yellow,
2060
+ blockquote: chalk2.gray.italic,
2061
+ hr: (input3) => {
2062
+ const w = Math.min(options.width ?? (process.stdout.columns || 80), 80);
2063
+ return chalk2.gray(input3 && input3.trim() ? input3 : "\u2500".repeat(w));
2064
+ },
2065
+ table: chalk2.reset,
2066
+ tableOptions: {
2067
+ style: {
2068
+ head: ["cyan", "bold"],
2069
+ border: ["gray"]
2070
+ }
2071
+ },
2072
+ tab: 2,
2073
+ showSectionPrefix: true,
2074
+ reflowText: false,
2075
+ width: options.width ?? (process.stdout.columns || 80),
2076
+ ...options
2077
+ };
2078
+ const marked = new Marked();
2079
+ marked.use(markedTerminal(terminalOptions, options.highlightOptions));
2080
+ return marked;
2081
+ }
2082
+ var defaultRenderer = null;
2083
+ function getDefaultRenderer() {
2084
+ if (!defaultRenderer) {
2085
+ defaultRenderer = createMarkdownRenderer();
2086
+ }
2087
+ return defaultRenderer;
2088
+ }
2089
+ function renderMarkdown(content, options) {
2090
+ if (!content || typeof content !== "string") {
2091
+ return "";
2092
+ }
2093
+ try {
2094
+ const renderer = options ? createMarkdownRenderer(options) : getDefaultRenderer();
2095
+ const result = renderer.parse(content);
2096
+ const parsedStr = typeof result === "string" ? result : String(result);
2097
+ return parsedStr.replace(/\n+$/, "");
2098
+ } catch {
2099
+ return content;
2100
+ }
2101
+ }
2102
+
2103
+ // src/agent/memory.ts
2104
+ import fs2 from "node:fs";
2105
+ import path2 from "node:path";
2106
+ import { HumanMessage as HumanMessage3, AIMessage as AIMessage2 } from "@langchain/core/messages";
2107
+ function getChatsDirPath() {
2108
+ return path2.join(getConfigDirPath(), "chats");
2109
+ }
2110
+ var ChatMemoryManager = class {
2111
+ chatsDir;
2112
+ constructor(customDir) {
2113
+ this.chatsDir = customDir || getChatsDirPath();
2114
+ this.ensureDirectory();
2115
+ }
2116
+ ensureDirectory() {
2117
+ if (!fs2.existsSync(this.chatsDir)) {
2118
+ try {
2119
+ fs2.mkdirSync(this.chatsDir, { recursive: true, mode: 448 });
2120
+ } catch {
2121
+ }
2122
+ }
2123
+ }
2124
+ getFilePath(sessionId) {
2125
+ const safeId = sessionId.replace(/[^a-zA-Z0-9_\-]/g, "_");
2126
+ return path2.join(this.chatsDir, `${safeId}.json`);
2127
+ }
2128
+ /**
2129
+ * Create a new chat session.
2130
+ */
2131
+ createSession(initialTitle, dbUrl) {
2132
+ const id = `chat-${Date.now()}`;
2133
+ const session = {
2134
+ id,
2135
+ title: initialTitle || "New Chat",
2136
+ createdAt: Date.now(),
2137
+ updatedAt: Date.now(),
2138
+ dbUrl,
2139
+ messages: []
2140
+ };
2141
+ this.saveSession(session);
2142
+ return session;
2143
+ }
2144
+ /**
2145
+ * Load an existing session by ID.
2146
+ */
2147
+ getSession(sessionId) {
2148
+ const file = this.getFilePath(sessionId);
2149
+ try {
2150
+ if (fs2.existsSync(file)) {
2151
+ const content = fs2.readFileSync(file, "utf-8");
2152
+ return JSON.parse(content);
2153
+ }
2154
+ } catch {
2155
+ return void 0;
2156
+ }
2157
+ return void 0;
2158
+ }
2159
+ /**
2160
+ * Save or update a session to disk.
2161
+ */
2162
+ saveSession(session) {
2163
+ this.ensureDirectory();
2164
+ session.updatedAt = Date.now();
2165
+ const file = this.getFilePath(session.id);
2166
+ try {
2167
+ fs2.writeFileSync(file, JSON.stringify(session, null, 2), {
2168
+ encoding: "utf-8",
2169
+ mode: 384
2170
+ });
2171
+ } catch {
2172
+ }
2173
+ }
2174
+ /**
2175
+ * List all stored sessions sorted by last updated time (newest first).
2176
+ */
2177
+ listSessions() {
2178
+ this.ensureDirectory();
2179
+ try {
2180
+ const files = fs2.readdirSync(this.chatsDir).filter((f) => f.endsWith(".json"));
2181
+ const summaries = [];
2182
+ for (const f of files) {
2183
+ try {
2184
+ const filePath = path2.join(this.chatsDir, f);
2185
+ const raw = fs2.readFileSync(filePath, "utf-8");
2186
+ const session = JSON.parse(raw);
2187
+ if (session && session.id) {
2188
+ summaries.push({
2189
+ id: session.id,
2190
+ title: session.title || "Untitled Chat",
2191
+ messageCount: session.messages ? session.messages.length : 0,
2192
+ createdAt: session.createdAt || 0,
2193
+ updatedAt: session.updatedAt || 0,
2194
+ dbUrl: session.dbUrl ? maskUrl(session.dbUrl) : void 0
2195
+ });
2196
+ }
2197
+ } catch {
2198
+ }
2199
+ }
2200
+ return summaries.sort((a, b) => b.updatedAt - a.updatedAt);
2201
+ } catch {
2202
+ return [];
2203
+ }
2204
+ }
2205
+ /**
2206
+ * Append a message to a session, updating session title if first turn.
2207
+ */
2208
+ addMessage(sessionId, message, dbUrl) {
2209
+ let session = this.getSession(sessionId);
2210
+ if (!session) {
2211
+ session = {
2212
+ id: sessionId,
2213
+ title: message.content.slice(0, 40).trim() || "New Chat",
2214
+ createdAt: Date.now(),
2215
+ updatedAt: Date.now(),
2216
+ dbUrl,
2217
+ messages: []
2218
+ };
2219
+ }
2220
+ if (dbUrl && !session.dbUrl) {
2221
+ session.dbUrl = dbUrl;
2222
+ }
2223
+ if (message.role === "user" && (session.title === "New Chat" || !session.title)) {
2224
+ session.title = message.content.slice(0, 45).trim();
2225
+ }
2226
+ session.messages.push({
2227
+ ...message,
2228
+ timestamp: Date.now()
2229
+ });
2230
+ this.saveSession(session);
2231
+ return session;
2232
+ }
2233
+ /**
2234
+ * Convert stored history into LangChain messages for model prompts.
2235
+ */
2236
+ toLangChainMessages(sessionId, maxTurns = 6) {
2237
+ const session = this.getSession(sessionId);
2238
+ if (!session || !session.messages || session.messages.length === 0) {
2239
+ return [];
2240
+ }
2241
+ const recent = session.messages.slice(-maxTurns);
2242
+ const result = [];
2243
+ for (const m of recent) {
2244
+ if (m.role === "user") {
2245
+ result.push(new HumanMessage3(m.content));
2246
+ } else if (m.role === "assistant") {
2247
+ let text = m.content;
2248
+ if (m.query) {
2249
+ text = `[Executed Query: ${m.query}]
2250
+ ${text}`;
2251
+ }
2252
+ result.push(new AIMessage2(text));
2253
+ }
2254
+ }
2255
+ return result;
2256
+ }
2257
+ /**
2258
+ * Get a compact text representation of recent turns for guardrail/target classification.
2259
+ */
2260
+ getRecentSummary(sessionId, maxTurns = 4) {
2261
+ const session = this.getSession(sessionId);
2262
+ if (!session || !session.messages || session.messages.length === 0) {
2263
+ return "";
2264
+ }
2265
+ const recent = session.messages.slice(-maxTurns);
2266
+ return recent.map((m) => {
2267
+ const prefix = m.role === "user" ? "User:" : "Assistant:";
2268
+ const q = m.query ? ` (Query: ${m.query})` : "";
2269
+ return `${prefix} ${m.content}${q}`;
2270
+ }).join("\n");
2271
+ }
2272
+ /**
2273
+ * Clear all messages from a session.
2274
+ */
2275
+ clearSession(sessionId) {
2276
+ const session = this.getSession(sessionId);
2277
+ if (session) {
2278
+ session.messages = [];
2279
+ this.saveSession(session);
2280
+ }
2281
+ }
2282
+ /**
2283
+ * Delete a session file permanently.
2284
+ */
2285
+ deleteSession(sessionId) {
2286
+ const file = this.getFilePath(sessionId);
2287
+ try {
2288
+ if (fs2.existsSync(file)) {
2289
+ fs2.unlinkSync(file);
2290
+ return true;
2291
+ }
2292
+ } catch {
2293
+ return false;
2294
+ }
2295
+ return false;
2296
+ }
2297
+ };
2298
+
1964
2299
  // src/repl.ts
1965
2300
  var ReplSession = class {
1966
2301
  adapter;
1967
2302
  model;
1968
2303
  provider;
1969
2304
  modelName;
2305
+ apiKey;
1970
2306
  strictMode;
1971
2307
  allowFullWipe;
1972
2308
  rowThreshold;
2309
+ renderMarkdown;
1973
2310
  isRunning = true;
1974
- sessionId = `session-${Date.now()}`;
2311
+ sessionId;
1975
2312
  agent;
2313
+ memoryManager;
2314
+ classifierOptions;
1976
2315
  constructor(options) {
1977
2316
  this.adapter = options.adapter;
1978
2317
  this.model = options.model;
1979
2318
  this.provider = options.provider;
1980
2319
  this.modelName = options.modelName;
2320
+ this.apiKey = options.apiKey;
1981
2321
  this.strictMode = options.strictMode;
1982
2322
  this.allowFullWipe = options.allowFullWipe;
1983
2323
  this.rowThreshold = options.rowThreshold ?? 50;
1984
- const classifierOptions = {
2324
+ this.renderMarkdown = options.renderMarkdown ?? true;
2325
+ this.classifierOptions = {
1985
2326
  strictMode: this.strictMode,
1986
2327
  allowFullWipe: this.allowFullWipe,
1987
2328
  rowThresholdForDangerousUpdate: this.rowThreshold
@@ -1989,8 +2330,17 @@ var ReplSession = class {
1989
2330
  this.agent = createDatabaseAgent({
1990
2331
  adapter: this.adapter,
1991
2332
  model: this.model,
1992
- classifierOptions
2333
+ classifierOptions: this.classifierOptions
1993
2334
  });
2335
+ this.memoryManager = new ChatMemoryManager();
2336
+ const initialSession = this.memoryManager.createSession("New Chat", this.adapter.connectionUrl);
2337
+ this.sessionId = initialSession.id;
2338
+ if (this.adapter.connectionUrl) {
2339
+ addSavedConnection(this.adapter.connectionUrl);
2340
+ }
2341
+ }
2342
+ getPromptText() {
2343
+ return chalk3.cyan(`sandal [${this.adapter.type}]> `);
1994
2344
  }
1995
2345
  async start() {
1996
2346
  this.setupSignalHandlers();
@@ -2000,12 +2350,11 @@ var ReplSession = class {
2000
2350
  output: process.stdout,
2001
2351
  terminal: true
2002
2352
  });
2003
- const promptText = chalk2.cyan(`sandal [${this.adapter.type}]> `);
2004
2353
  const askQuestion = (query) => {
2005
2354
  return new Promise((resolve) => rl.question(query, resolve));
2006
2355
  };
2007
2356
  while (this.isRunning) {
2008
- const input3 = await askQuestion(promptText);
2357
+ const input3 = await askQuestion(this.getPromptText());
2009
2358
  const trimmed = input3.trim();
2010
2359
  if (!trimmed) continue;
2011
2360
  if (["exit", "quit", ".exit", ".quit"].includes(trimmed.toLowerCase())) {
@@ -2029,26 +2378,77 @@ var ReplSession = class {
2029
2378
  continue;
2030
2379
  }
2031
2380
  if (trimmed === ".refresh") {
2032
- const spinner = ora(chalk2.blue("Refreshing schema cache...")).start();
2381
+ const spinner = ora(chalk3.blue("Refreshing schema cache...")).start();
2033
2382
  try {
2034
2383
  await this.adapter.inspectSchema(true);
2035
- spinner.succeed(chalk2.green("Schema refreshed successfully."));
2384
+ spinner.succeed(chalk3.green("Schema refreshed successfully."));
2036
2385
  } catch (err) {
2037
- spinner.fail(chalk2.red(`Failed to refresh schema: ${err.message}`));
2386
+ spinner.fail(chalk3.red(`Failed to refresh schema: ${err.message}`));
2387
+ }
2388
+ continue;
2389
+ }
2390
+ if (trimmed.startsWith(".md") || trimmed.startsWith(".markdown")) {
2391
+ const entityOrText = trimmed.replace(/^\.(?:markdown|md)\s*/, "").trim();
2392
+ this.handleRenderMarkdown(entityOrText);
2393
+ continue;
2394
+ }
2395
+ if (trimmed.startsWith(".connect") || trimmed.startsWith(".switch") || trimmed === ".connections" || trimmed === ".databases") {
2396
+ const targetUrl = trimmed.replace(/^\.(?:connect|switch)\s*/, "").trim();
2397
+ await this.handleSwitchConnection(targetUrl, askQuestion);
2398
+ continue;
2399
+ }
2400
+ if (trimmed.startsWith(".model")) {
2401
+ const targetModel = trimmed.replace(/^\.model\s*/, "").trim();
2402
+ await this.handleModelCommand(targetModel, askQuestion);
2403
+ continue;
2404
+ }
2405
+ if (trimmed.startsWith(".provider")) {
2406
+ const targetProvider = trimmed.replace(/^\.provider\s*/, "").trim();
2407
+ await this.handleProviderCommand(targetProvider, askQuestion);
2408
+ continue;
2409
+ }
2410
+ if (trimmed.startsWith(".key")) {
2411
+ const keyArg = trimmed.replace(/^\.key\s*/, "").trim();
2412
+ await this.handleKeyCommand(keyArg, askQuestion);
2413
+ continue;
2414
+ }
2415
+ if (trimmed === ".chats") {
2416
+ this.handleListChats();
2417
+ continue;
2418
+ }
2419
+ if (trimmed === ".new") {
2420
+ this.handleNewChat();
2421
+ continue;
2422
+ }
2423
+ if (trimmed.startsWith(".chat")) {
2424
+ const chatArg = trimmed.replace(/^\.chat\s*/, "").trim();
2425
+ if (!chatArg || chatArg === "new") {
2426
+ this.handleNewChat();
2427
+ } else {
2428
+ this.handleSwitchChat(chatArg);
2038
2429
  }
2039
2430
  continue;
2040
2431
  }
2041
- const intentSpinner = ora(chalk2.blue("Analyzing intent...")).start();
2042
- const intent = await classifyIntent(trimmed, this.model);
2432
+ if (trimmed === ".history") {
2433
+ this.handleShowHistory();
2434
+ continue;
2435
+ }
2436
+ if (trimmed === ".clear-chat" || trimmed === ".clear-history") {
2437
+ this.handleClearChat();
2438
+ continue;
2439
+ }
2440
+ const intentSpinner = ora(chalk3.blue("Analyzing intent...")).start();
2441
+ const historySummary = this.memoryManager.getRecentSummary(this.sessionId, 4);
2442
+ const intent = await classifyIntent(trimmed, this.model, historySummary);
2043
2443
  intentSpinner.stop();
2044
2444
  if (!intent.isDatabaseTask) {
2045
2445
  console.log(
2046
2446
  boxen2(
2047
- `${chalk2.bold.red("\u{1F6E1}\uFE0F GUARDRAIL ENFORCEMENT")}
2447
+ `${chalk3.bold.red("\u{1F6E1}\uFE0F GUARDRAIL ENFORCEMENT")}
2048
2448
 
2049
- ${chalk2.yellow(REFUSAL_MESSAGE)}
2449
+ ${chalk3.yellow(REFUSAL_MESSAGE)}
2050
2450
 
2051
- ${chalk2.gray(
2451
+ ${chalk3.gray(
2052
2452
  `Reason: ${intent.reason || "Non-database request rejected"}`
2053
2453
  )}`,
2054
2454
  {
@@ -2061,11 +2461,13 @@ ${chalk2.gray(
2061
2461
  );
2062
2462
  continue;
2063
2463
  }
2064
- const agentSpinner = ora(chalk2.cyan("Agent planning query...")).start();
2464
+ const agentSpinner = ora(chalk3.cyan("Agent planning query...")).start();
2065
2465
  try {
2466
+ const historyMessages = this.memoryManager.toLangChainMessages(this.sessionId, 6);
2066
2467
  const result = await this.agent.invoke(
2067
2468
  {
2068
- userInput: trimmed
2469
+ userInput: trimmed,
2470
+ messages: historyMessages
2069
2471
  },
2070
2472
  {
2071
2473
  configurable: {
@@ -2078,34 +2480,441 @@ ${chalk2.gray(
2078
2480
  this.renderRowsTable(result.queryResult);
2079
2481
  }
2080
2482
  if (result.conclusion) {
2081
- console.log("\n" + chalk2.green("\u{1F916} Answer:"));
2082
- console.log(chalk2.white(result.conclusion) + "\n");
2483
+ console.log("\n" + chalk3.green("\u{1F916} Answer:"));
2484
+ if (this.renderMarkdown) {
2485
+ console.log(renderMarkdown(result.conclusion) + "\n");
2486
+ } else {
2487
+ console.log(chalk3.white(result.conclusion) + "\n");
2488
+ }
2489
+ }
2490
+ this.memoryManager.addMessage(
2491
+ this.sessionId,
2492
+ { role: "user", content: trimmed },
2493
+ this.adapter.connectionUrl
2494
+ );
2495
+ if (result.conclusion) {
2496
+ const queryStr = result.generatedQuery?.sql || result.generatedQuery?.rawDisplay;
2497
+ this.memoryManager.addMessage(
2498
+ this.sessionId,
2499
+ {
2500
+ role: "assistant",
2501
+ content: result.conclusion,
2502
+ query: queryStr,
2503
+ rowCount: result.queryResult?.rowCount
2504
+ },
2505
+ this.adapter.connectionUrl
2506
+ );
2083
2507
  }
2084
2508
  } catch (err) {
2085
- agentSpinner.fail(chalk2.red(`Execution failed: ${err.message}`));
2509
+ agentSpinner.fail(chalk3.red(`Execution failed: ${err.message}`));
2086
2510
  }
2087
2511
  }
2088
2512
  rl.close();
2089
2513
  await this.shutdown();
2090
2514
  }
2515
+ async handleSwitchConnection(targetUrl, ask) {
2516
+ let urlToConnect = targetUrl;
2517
+ if (!urlToConnect) {
2518
+ const saved = getSavedConnections();
2519
+ console.log(chalk3.bold.cyan("\nDatabase Connections:"));
2520
+ if (saved.length === 0) {
2521
+ console.log(chalk3.gray(" (No previous connections saved)"));
2522
+ } else {
2523
+ saved.forEach((url, i) => {
2524
+ const isCurrent = url === this.adapter.connectionUrl;
2525
+ const currentTag = isCurrent ? chalk3.green(" (Current)") : "";
2526
+ console.log(` [${i + 1}] ${maskUrl(url)}${currentTag}`);
2527
+ });
2528
+ }
2529
+ console.log(chalk3.gray(" [N] Enter a new connection URL"));
2530
+ console.log(chalk3.gray(" [C] Cancel\n"));
2531
+ const choice = (await ask(chalk3.cyan("Select a connection [number, N, C]: "))).trim();
2532
+ if (!choice || choice.toLowerCase() === "c") {
2533
+ return;
2534
+ }
2535
+ if (choice.toLowerCase() === "n") {
2536
+ const entered = (await ask(chalk3.cyan("Enter database connection URL: "))).trim();
2537
+ if (!entered) return;
2538
+ urlToConnect = entered;
2539
+ } else {
2540
+ const num = parseInt(choice, 10);
2541
+ if (!isNaN(num) && num >= 1 && num <= saved.length) {
2542
+ urlToConnect = saved[num - 1];
2543
+ } else {
2544
+ console.log(chalk3.red("Invalid selection."));
2545
+ return;
2546
+ }
2547
+ }
2548
+ }
2549
+ if (urlToConnect === this.adapter.connectionUrl && this.adapter.isConnected()) {
2550
+ console.log(chalk3.yellow("\nAlready connected to this database.\n"));
2551
+ return;
2552
+ }
2553
+ try {
2554
+ detectDatabaseType(urlToConnect);
2555
+ } catch (e) {
2556
+ console.log(chalk3.red(`Invalid connection URL: ${e.message}`));
2557
+ return;
2558
+ }
2559
+ const spinner = ora(`Connecting to ${maskUrl(urlToConnect)}...`).start();
2560
+ try {
2561
+ const newAdapter = createDatabaseAdapter(urlToConnect);
2562
+ await newAdapter.connect();
2563
+ await newAdapter.inspectSchema(true);
2564
+ try {
2565
+ await this.adapter.disconnect();
2566
+ } catch {
2567
+ }
2568
+ this.adapter = newAdapter;
2569
+ this.agent = createDatabaseAgent({
2570
+ adapter: this.adapter,
2571
+ model: this.model,
2572
+ classifierOptions: this.classifierOptions
2573
+ });
2574
+ addSavedConnection(urlToConnect);
2575
+ spinner.succeed(
2576
+ chalk3.green(
2577
+ `Switched to ${newAdapter.type.toUpperCase()} database: ${newAdapter.databaseName} (${newAdapter.getMaskedUrl()})`
2578
+ )
2579
+ );
2580
+ console.log(
2581
+ chalk3.gray(
2582
+ `Chat session [${this.sessionId}] continuing with conversation memory on the new database.
2583
+ `
2584
+ )
2585
+ );
2586
+ } catch (err) {
2587
+ spinner.fail(chalk3.red(`Failed to connect to database: ${err.message}`));
2588
+ }
2589
+ }
2590
+ async handleModelCommand(newModel, ask) {
2591
+ let targetModel = newModel;
2592
+ if (!targetModel) {
2593
+ console.log(
2594
+ chalk3.bold.cyan(`
2595
+ Current Model: `) + chalk3.white(this.modelName) + chalk3.gray(` (${this.provider})`)
2596
+ );
2597
+ console.log(chalk3.bold.yellow("Suggested models for " + this.provider + ":"));
2598
+ const commonModels = {
2599
+ google: ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"],
2600
+ openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
2601
+ anthropic: [
2602
+ "claude-3-5-sonnet-latest",
2603
+ "claude-3-5-haiku-latest",
2604
+ "claude-3-7-sonnet-latest"
2605
+ ]
2606
+ };
2607
+ const list = commonModels[this.provider] || [];
2608
+ list.forEach((m, i) => {
2609
+ const isCurrent = m === this.modelName ? chalk3.green(" (Current)") : "";
2610
+ console.log(` [${i + 1}] ${m}${isCurrent}`);
2611
+ });
2612
+ console.log(chalk3.gray(" [O] Other (type custom model name)"));
2613
+ 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);
2617
+ if (!isNaN(num) && num >= 1 && num <= list.length) {
2618
+ targetModel = list[num - 1];
2619
+ } else if (input3.toLowerCase() === "o") {
2620
+ const custom = (await ask(chalk3.cyan("Enter model name: "))).trim();
2621
+ if (!custom) return;
2622
+ targetModel = custom;
2623
+ } else {
2624
+ targetModel = input3;
2625
+ }
2626
+ }
2627
+ if (!this.apiKey) {
2628
+ const resolved = resolveCredentials({ cliProvider: this.provider });
2629
+ if (resolved.apiKey) {
2630
+ this.apiKey = resolved.apiKey;
2631
+ } else {
2632
+ console.log(chalk3.yellow(`
2633
+ No API key found for ${this.provider.toUpperCase()}.`));
2634
+ this.apiKey = (await ask(chalk3.cyan(`Enter API key for ${this.provider.toUpperCase()}: `))).trim();
2635
+ if (!this.apiKey) {
2636
+ console.log(chalk3.red("API key is required."));
2637
+ return;
2638
+ }
2639
+ }
2640
+ }
2641
+ try {
2642
+ this.model = createChatModel({
2643
+ provider: this.provider,
2644
+ model: targetModel,
2645
+ apiKey: this.apiKey
2646
+ });
2647
+ this.modelName = targetModel;
2648
+ this.agent = createDatabaseAgent({
2649
+ adapter: this.adapter,
2650
+ model: this.model,
2651
+ classifierOptions: this.classifierOptions
2652
+ });
2653
+ console.log(chalk3.green(`
2654
+ Updated active model to: ${targetModel} [${this.provider}]
2655
+ `));
2656
+ } catch (err) {
2657
+ console.log(chalk3.red(`
2658
+ Failed to update model: ${err.message}
2659
+ `));
2660
+ }
2661
+ }
2662
+ async handleProviderCommand(newProvider, ask) {
2663
+ let p = newProvider.toLowerCase();
2664
+ if (!["google", "openai", "anthropic"].includes(p)) {
2665
+ console.log(chalk3.bold.cyan(`
2666
+ Current Provider: `) + chalk3.blue(this.provider));
2667
+ console.log("Available providers:");
2668
+ console.log(" [1] Google Gemini (google)");
2669
+ console.log(" [2] OpenAI (openai)");
2670
+ console.log(" [3] Anthropic (anthropic)");
2671
+ console.log(" [C] Cancel\n");
2672
+ const ans = (await ask(chalk3.cyan("Select provider [1-3, name, C]: "))).trim().toLowerCase();
2673
+ if (!ans || ans === "c") return;
2674
+ if (ans === "1" || ans === "google") p = "google";
2675
+ else if (ans === "2" || ans === "openai") p = "openai";
2676
+ else if (ans === "3" || ans === "anthropic") p = "anthropic";
2677
+ else {
2678
+ console.log(chalk3.red("Invalid provider."));
2679
+ return;
2680
+ }
2681
+ }
2682
+ const resolved = resolveCredentials({ cliProvider: p });
2683
+ let key = resolved.apiKey;
2684
+ if (!key) {
2685
+ console.log(chalk3.yellow(`
2686
+ No API key found for ${p.toUpperCase()}.`));
2687
+ key = (await ask(chalk3.cyan(`Enter API key for ${p.toUpperCase()}: `))).trim();
2688
+ if (!key) {
2689
+ console.log(chalk3.red("API key cannot be empty."));
2690
+ return;
2691
+ }
2692
+ const save = (await ask(chalk3.cyan("Save this API key to ~/.sandal/config.json? (Y/n): "))).trim().toLowerCase();
2693
+ if (save !== "n") {
2694
+ if (p === "google") writeConfig({ geminiApiKey: key, defaultProvider: "google" });
2695
+ else if (p === "openai") writeConfig({ openaiApiKey: key, defaultProvider: "openai" });
2696
+ else if (p === "anthropic") writeConfig({ anthropicApiKey: key, defaultProvider: "anthropic" });
2697
+ }
2698
+ }
2699
+ const defaultModel = getDefaultModelForProvider(p);
2700
+ try {
2701
+ this.model = createChatModel({
2702
+ provider: p,
2703
+ model: defaultModel,
2704
+ apiKey: key
2705
+ });
2706
+ this.provider = p;
2707
+ this.modelName = defaultModel;
2708
+ this.apiKey = key;
2709
+ this.agent = createDatabaseAgent({
2710
+ adapter: this.adapter,
2711
+ model: this.model,
2712
+ classifierOptions: this.classifierOptions
2713
+ });
2714
+ console.log(
2715
+ chalk3.green(`
2716
+ Switched provider to ${p.toUpperCase()} with model ${defaultModel}.
2717
+ `)
2718
+ );
2719
+ } catch (err) {
2720
+ console.log(chalk3.red(`Failed to switch provider: ${err.message}
2721
+ `));
2722
+ }
2723
+ }
2724
+ async handleKeyCommand(arg, ask) {
2725
+ const sub = arg.toLowerCase().trim();
2726
+ if (sub === "remove" || sub === "clear" || sub === "delete") {
2727
+ const confirmAns = (await ask(chalk3.yellow(`Remove stored API key for provider "${this.provider}"? (y/N): `))).trim().toLowerCase();
2728
+ if (confirmAns === "y" || confirmAns === "yes") {
2729
+ removeApiKey(this.provider);
2730
+ this.apiKey = void 0;
2731
+ console.log(chalk3.green(`
2732
+ Removed stored API key for "${this.provider}" from ~/.sandal/config.json.`));
2733
+ console.log(chalk3.gray(`Subsequent requests with this provider will prompt for a key.
2734
+ `));
2735
+ }
2736
+ return;
2737
+ }
2738
+ if (sub.startsWith("set")) {
2739
+ let newKey = sub.replace(/^set\s*/, "").trim();
2740
+ if (!newKey) {
2741
+ newKey = (await ask(chalk3.cyan(`Enter new API key for ${this.provider}: `))).trim();
2742
+ }
2743
+ if (!newKey) {
2744
+ console.log(chalk3.red("API key cannot be empty."));
2745
+ return;
2746
+ }
2747
+ this.apiKey = newKey;
2748
+ if (this.provider === "google") writeConfig({ geminiApiKey: newKey });
2749
+ else if (this.provider === "openai") writeConfig({ openaiApiKey: newKey });
2750
+ else if (this.provider === "anthropic") writeConfig({ anthropicApiKey: newKey });
2751
+ this.model = createChatModel({
2752
+ provider: this.provider,
2753
+ model: this.modelName,
2754
+ apiKey: newKey
2755
+ });
2756
+ this.agent = createDatabaseAgent({
2757
+ adapter: this.adapter,
2758
+ model: this.model,
2759
+ classifierOptions: this.classifierOptions
2760
+ });
2761
+ console.log(chalk3.green(`
2762
+ Updated API key for "${this.provider}" and refreshed model.
2763
+ `));
2764
+ return;
2765
+ }
2766
+ console.log(chalk3.bold.cyan("\nAPI Key Status:"));
2767
+ console.log(` Provider: ${chalk3.blue(this.provider)}`);
2768
+ console.log(` Model: ${chalk3.white(this.modelName)}`);
2769
+ console.log(
2770
+ ` Active Key: ${this.apiKey ? chalk3.yellow(maskApiKey(this.apiKey)) : chalk3.red("None (not set)")}`
2771
+ );
2772
+ console.log(chalk3.gray("\nUsage:"));
2773
+ console.log(chalk3.gray(" .key remove - Remove stored key from ~/.sandal/config.json"));
2774
+ console.log(chalk3.gray(" .key set <api-key> - Update API key on the fly\n"));
2775
+ }
2776
+ handleListChats() {
2777
+ const list = this.memoryManager.listSessions();
2778
+ if (list.length === 0) {
2779
+ console.log(chalk3.yellow("\nNo saved chat sessions found.\n"));
2780
+ return;
2781
+ }
2782
+ console.log(chalk3.bold.cyan("\nSaved Chat Sessions:"));
2783
+ list.forEach((s, i) => {
2784
+ const isActive = s.id === this.sessionId;
2785
+ const activeMarker = isActive ? chalk3.green(" [ACTIVE]") : "";
2786
+ const dateStr = new Date(s.updatedAt).toLocaleString();
2787
+ console.log(
2788
+ ` [${i + 1}] ${chalk3.bold(s.title)}${activeMarker}
2789
+ ID: ${chalk3.gray(s.id)} | ${chalk3.yellow(s.messageCount + " messages")} | ${chalk3.gray(dateStr)}`
2790
+ );
2791
+ });
2792
+ console.log(chalk3.gray("\nCommands: .chat <id|num> to switch, .new to start fresh chat\n"));
2793
+ }
2794
+ handleNewChat() {
2795
+ const newSession = this.memoryManager.createSession("New Chat", this.adapter.connectionUrl);
2796
+ this.sessionId = newSession.id;
2797
+ this.agent = createDatabaseAgent({
2798
+ adapter: this.adapter,
2799
+ model: this.model,
2800
+ classifierOptions: this.classifierOptions
2801
+ });
2802
+ console.log(chalk3.green(`
2803
+ Started fresh chat session: ${this.sessionId}`));
2804
+ console.log(chalk3.gray("Chat memory is clean for this new session.\n"));
2805
+ }
2806
+ handleSwitchChat(arg) {
2807
+ const list = this.memoryManager.listSessions();
2808
+ let targetId;
2809
+ const num = parseInt(arg, 10);
2810
+ if (!isNaN(num) && num >= 1 && num <= list.length) {
2811
+ targetId = list[num - 1].id;
2812
+ } else {
2813
+ const found = list.find(
2814
+ (s) => s.id === arg || s.id.toLowerCase().includes(arg.toLowerCase())
2815
+ );
2816
+ if (found) targetId = found.id;
2817
+ }
2818
+ if (!targetId) {
2819
+ console.log(chalk3.red(`
2820
+ Chat session "${arg}" not found. Use .chats to list sessions.
2821
+ `));
2822
+ return;
2823
+ }
2824
+ const session = this.memoryManager.getSession(targetId);
2825
+ if (!session) {
2826
+ console.log(chalk3.red(`
2827
+ Could not load chat session "${targetId}".
2828
+ `));
2829
+ return;
2830
+ }
2831
+ this.sessionId = session.id;
2832
+ this.agent = createDatabaseAgent({
2833
+ adapter: this.adapter,
2834
+ model: this.model,
2835
+ classifierOptions: this.classifierOptions
2836
+ });
2837
+ console.log(
2838
+ chalk3.green(
2839
+ `
2840
+ Switched to chat: "${session.title}" (${session.messages.length} messages in memory).
2841
+ `
2842
+ )
2843
+ );
2844
+ }
2845
+ handleShowHistory() {
2846
+ const session = this.memoryManager.getSession(this.sessionId);
2847
+ if (!session || session.messages.length === 0) {
2848
+ console.log(chalk3.yellow(`
2849
+ No message history in current chat (${this.sessionId}).
2850
+ `));
2851
+ return;
2852
+ }
2853
+ console.log(chalk3.bold.cyan(`
2854
+ Chat History: "${session.title}" [${this.sessionId}]:`));
2855
+ console.log(chalk3.gray("\u2500".repeat(60)));
2856
+ for (const msg of session.messages) {
2857
+ const time = new Date(msg.timestamp).toLocaleTimeString();
2858
+ if (msg.role === "user") {
2859
+ console.log(`${chalk3.bold.blue("\u{1F464} User")} ${chalk3.gray(`(${time})`)}:
2860
+ ${msg.content}`);
2861
+ } else if (msg.role === "assistant") {
2862
+ if (msg.query) {
2863
+ console.log(` ${chalk3.gray(`[Query: ${msg.query}]`)}`);
2864
+ }
2865
+ console.log(`${chalk3.bold.green("\u{1F916} Assistant")} ${chalk3.gray(`(${time})`)}:
2866
+ ${msg.content}`);
2867
+ }
2868
+ console.log(chalk3.gray("\u2500".repeat(60)));
2869
+ }
2870
+ console.log("");
2871
+ }
2872
+ handleClearChat() {
2873
+ this.memoryManager.clearSession(this.sessionId);
2874
+ this.agent = createDatabaseAgent({
2875
+ adapter: this.adapter,
2876
+ model: this.model,
2877
+ classifierOptions: this.classifierOptions
2878
+ });
2879
+ console.log(chalk3.green(`
2880
+ Chat memory for session [${this.sessionId}] has been cleared.
2881
+ `));
2882
+ }
2883
+ handleRenderMarkdown(arg) {
2884
+ if (!arg) {
2885
+ console.log(chalk3.yellow("Usage: .md <markdown text or file path>"));
2886
+ console.log(chalk3.gray("Example: .md # Hello World"));
2887
+ console.log(chalk3.gray("Example: .md ./README.md"));
2888
+ return;
2889
+ }
2890
+ try {
2891
+ if (fs3.existsSync(arg) && fs3.statSync(arg).isFile()) {
2892
+ const fileContent = fs3.readFileSync(arg, "utf-8");
2893
+ console.log("\n" + renderMarkdown(fileContent) + "\n");
2894
+ return;
2895
+ }
2896
+ } catch {
2897
+ }
2898
+ console.log("\n" + renderMarkdown(arg) + "\n");
2899
+ }
2091
2900
  renderRowsTable(queryResult) {
2092
2901
  const rows = queryResult.rows;
2093
2902
  if (!rows || rows.length === 0) {
2094
- console.log(chalk2.gray("(0 rows returned)"));
2903
+ console.log(chalk3.gray("(0 rows returned)"));
2095
2904
  return;
2096
2905
  }
2097
2906
  const fields = queryResult.fields && queryResult.fields.length > 0 ? queryResult.fields : Object.keys(rows[0] || {});
2098
2907
  if (fields.length === 0) return;
2099
2908
  const displayRows = rows.slice(0, 25);
2100
2909
  const table = new Table({
2101
- head: fields.map((f) => chalk2.cyan.bold(f)),
2910
+ head: fields.map((f) => chalk3.cyan.bold(f)),
2102
2911
  style: { head: [], border: ["gray"] }
2103
2912
  });
2104
2913
  for (const r of displayRows) {
2105
2914
  table.push(
2106
2915
  fields.map((f) => {
2107
2916
  const val = r[f];
2108
- if (val === null || val === void 0) return chalk2.gray("NULL");
2917
+ if (val === null || val === void 0) return chalk3.gray("NULL");
2109
2918
  if (typeof val === "object") {
2110
2919
  try {
2111
2920
  return JSON.stringify(val);
@@ -2119,94 +2928,99 @@ ${chalk2.gray(
2119
2928
  }
2120
2929
  console.log(table.toString());
2121
2930
  if (rows.length > 25) {
2122
- console.log(chalk2.yellow(`Showing first 25 of ${rows.length.toLocaleString()} rows.`));
2931
+ console.log(chalk3.yellow(`Showing first 25 of ${rows.length.toLocaleString()} rows.`));
2123
2932
  }
2124
2933
  console.log(
2125
- chalk2.gray(
2126
- `Total rows: ${queryResult.rowCount ?? rows.length} | Duration: ${queryResult.durationMs ?? 0}ms`
2934
+ chalk3.gray(
2935
+ `Total rows: ${queryResult.rowCount ?? rows.length} | Execution duration: ${queryResult.durationMs}ms
2936
+ `
2127
2937
  )
2128
2938
  );
2129
2939
  }
2130
2940
  async handleListEntities() {
2131
- const spinner = ora(chalk2.blue("Loading schema...")).start();
2941
+ const spinner = ora(chalk3.blue("Inspecting schema...")).start();
2132
2942
  try {
2133
- const schema = await this.adapter.inspectSchema();
2943
+ const schema = await this.adapter.inspectSchema(false);
2134
2944
  spinner.stop();
2135
2945
  if (schema.type === "postgres") {
2136
2946
  const tables = schema.tables || [];
2137
2947
  if (tables.length === 0) {
2138
- console.log(chalk2.yellow("No tables found in public schema."));
2948
+ console.log(chalk3.yellow("No tables found in public schema."));
2139
2949
  return;
2140
2950
  }
2141
- console.log(chalk2.bold.cyan(`
2951
+ console.log(chalk3.bold.cyan(`
2142
2952
  Tables in "${schema.databaseName}":`));
2143
2953
  for (const t of tables) {
2144
2954
  console.log(
2145
- ` \u2022 ${chalk2.white.bold(t.schema + "." + t.name)} (~${(t.approximateRowCount ?? 0).toLocaleString()} rows, ${t.columns.length} columns)`
2955
+ ` \u2022 ${chalk3.bold(t.name)} ${chalk3.gray(`(~${t.approximateRowCount ?? 0} rows, ${t.columns.length} columns)`)}`
2146
2956
  );
2147
2957
  }
2148
2958
  console.log("");
2149
2959
  } else {
2150
2960
  const collections = schema.collections || [];
2151
2961
  if (collections.length === 0) {
2152
- console.log(chalk2.yellow("No collections found."));
2962
+ console.log(chalk3.yellow("No collections found."));
2153
2963
  return;
2154
2964
  }
2155
- console.log(chalk2.bold.cyan(`
2965
+ console.log(chalk3.bold.cyan(`
2156
2966
  Collections in "${schema.databaseName}":`));
2157
2967
  for (const c of collections) {
2158
2968
  console.log(
2159
- ` \u2022 ${chalk2.white.bold(c.name)} (~${(c.documentCount ?? 0).toLocaleString()} documents, ${c.fields.length} inferred fields)`
2969
+ ` \u2022 ${chalk3.bold(c.name)} ${chalk3.gray(`(${c.documentCount ?? 0} docs, ${c.fields.length} sampled fields)`)}`
2160
2970
  );
2161
2971
  }
2162
2972
  console.log("");
2163
2973
  }
2164
2974
  } catch (err) {
2165
- spinner.fail(chalk2.red(`Failed to fetch entities: ${err.message}`));
2975
+ spinner.fail(chalk3.red(`Failed to list entities: ${err.message}`));
2166
2976
  }
2167
2977
  }
2168
2978
  async handleShowSchema(entityName) {
2169
- const spinner = ora(chalk2.blue("Loading schema details...")).start();
2979
+ const spinner = ora(chalk3.blue("Fetching schema details...")).start();
2170
2980
  try {
2171
- const schema = await this.adapter.inspectSchema();
2981
+ const schema = await this.adapter.inspectSchema(false);
2172
2982
  spinner.stop();
2173
2983
  if (!entityName) {
2174
2984
  console.log("\n" + formatSchemaForPrompt(schema) + "\n");
2175
2985
  return;
2176
2986
  }
2177
2987
  if (schema.type === "postgres") {
2178
- const table = schema.tables?.find(
2179
- (t) => t.name.toLowerCase() === entityName.toLowerCase() || `${t.schema}.${t.name}`.toLowerCase() === entityName.toLowerCase()
2988
+ const table = (schema.tables || []).find(
2989
+ (t) => t.name.toLowerCase() === entityName.toLowerCase()
2180
2990
  );
2181
2991
  if (!table) {
2182
- console.log(chalk2.red(`Table "${entityName}" not found.`));
2992
+ console.log(chalk3.red(`Table "${entityName}" not found.`));
2183
2993
  return;
2184
2994
  }
2185
- console.log(chalk2.bold.cyan(`
2995
+ console.log(chalk3.bold.cyan(`
2186
2996
  Schema for table: ${table.schema}.${table.name}`));
2187
2997
  const tableDetails = new Table({
2188
- head: ["Column", "Type", "Nullable", "Default", "Primary Key"].map((h) => chalk2.cyan.bold(h))
2998
+ head: ["Column", "Data Type", "Nullable", "Primary Key", "Default"].map(
2999
+ (h) => chalk3.cyan.bold(h)
3000
+ )
2189
3001
  });
2190
- for (const col of table.columns) {
3002
+ for (const c of table.columns) {
2191
3003
  tableDetails.push([
2192
- col.name,
2193
- col.dataType,
2194
- col.isNullable ? "YES" : "NO",
2195
- col.defaultValue || "-",
2196
- col.isPrimaryKey ? "PK" : ""
3004
+ c.name,
3005
+ c.dataType,
3006
+ c.isNullable ? "YES" : "NO",
3007
+ c.isPrimaryKey ? chalk3.green("YES") : "NO",
3008
+ c.defaultValue ?? chalk3.gray("NULL")
2197
3009
  ]);
2198
3010
  }
2199
3011
  console.log(tableDetails.toString());
2200
3012
  } else {
2201
- const collection = schema.collections?.find((c) => c.name.toLowerCase() === entityName.toLowerCase());
3013
+ const collection = (schema.collections || []).find(
3014
+ (c) => c.name.toLowerCase() === entityName.toLowerCase()
3015
+ );
2202
3016
  if (!collection) {
2203
- console.log(chalk2.red(`Collection "${entityName}" not found.`));
3017
+ console.log(chalk3.red(`Collection "${entityName}" not found.`));
2204
3018
  return;
2205
3019
  }
2206
- console.log(chalk2.bold.cyan(`
3020
+ console.log(chalk3.bold.cyan(`
2207
3021
  Schema for collection: ${collection.name}`));
2208
3022
  const colDetails = new Table({
2209
- head: ["Field", "Inferred Types"].map((h) => chalk2.cyan.bold(h))
3023
+ head: ["Field", "Inferred Types"].map((h) => chalk3.cyan.bold(h))
2210
3024
  });
2211
3025
  for (const f of collection.fields) {
2212
3026
  colDetails.push([f.name, f.types.join(", ")]);
@@ -2214,21 +3028,22 @@ Schema for collection: ${collection.name}`));
2214
3028
  console.log(colDetails.toString());
2215
3029
  }
2216
3030
  } catch (err) {
2217
- spinner.fail(chalk2.red(`Failed to inspect schema: ${err.message}`));
3031
+ spinner.fail(chalk3.red(`Failed to inspect schema: ${err.message}`));
2218
3032
  }
2219
3033
  }
2220
3034
  printWelcomeBanner() {
2221
3035
  const banner = [
2222
- chalk2.bold.cyan("SANDAL: Safe Agentic Natural-language Database Access Layer"),
2223
- chalk2.gray("LangGraph + Multi-Provider AI (Gemini, OpenAI, Anthropic)"),
3036
+ chalk3.bold.cyan("SANDAL: Safe Agentic Natural-language Database Access Layer"),
3037
+ chalk3.gray("LangGraph + Multi-Provider AI (Gemini, OpenAI, Anthropic)"),
2224
3038
  "",
2225
- `${chalk2.bold("Database:")} ${chalk2.green(this.adapter.type.toUpperCase())} (${this.adapter.getMaskedUrl()})`,
2226
- `${chalk2.bold("LLM Provider:")} ${chalk2.blue(this.provider)} [${chalk2.white(this.modelName)}]`,
2227
- `${chalk2.bold("Strict Mode:")} ${this.strictMode ? chalk2.green("ON (Full wipes blocked)") : chalk2.yellow("OFF")}`,
2228
- `${chalk2.bold("Allow Full Wipe:")} ${this.allowFullWipe ? chalk2.red("ENABLED") : chalk2.gray("NO")}`,
3039
+ `${chalk3.bold("Database:")} ${chalk3.green(this.adapter.type.toUpperCase())} (${this.adapter.getMaskedUrl()})`,
3040
+ `${chalk3.bold("LLM Provider:")} ${chalk3.blue(this.provider)} [${chalk3.white(this.modelName)}]`,
3041
+ `${chalk3.bold("Chat Session:")} ${chalk3.yellow(this.sessionId)}`,
3042
+ `${chalk3.bold("Strict Mode:")} ${this.strictMode ? chalk3.green("ON (Full wipes blocked)") : chalk3.yellow("OFF")}`,
3043
+ `${chalk3.bold("Markdown:")} ${this.renderMarkdown ? chalk3.green("ENABLED") : chalk3.gray("DISABLED")}`,
2229
3044
  "",
2230
- chalk2.gray("Type your natural language request or SQL/Mongo query."),
2231
- chalk2.gray("Commands: .tables, .schema [name], .refresh, .help, exit")
3045
+ chalk3.gray("Type your natural language request or SQL/Mongo query."),
3046
+ chalk3.gray("Commands: .connect, .model, .chats, .new, .history, .key, .help, exit")
2232
3047
  ].join("\n");
2233
3048
  console.log(
2234
3049
  boxen2(banner, {
@@ -2243,20 +3058,30 @@ Schema for collection: ${collection.name}`));
2243
3058
  console.log(
2244
3059
  boxen2(
2245
3060
  [
2246
- chalk2.bold.cyan("SANDAL REPL Commands:"),
3061
+ chalk3.bold.cyan("SANDAL REPL Commands:"),
2247
3062
  "",
2248
- `${chalk2.bold(".tables / .collections")} List all tables or collections with row counts`,
2249
- `${chalk2.bold(".schema [name]")} Show columns, types, and indexes for a table`,
2250
- `${chalk2.bold(".refresh")} Force refresh cached schema introspection`,
2251
- `${chalk2.bold(".clear")} Clear terminal screen`,
2252
- `${chalk2.bold(".help")} Display this command reference`,
2253
- `${chalk2.bold("exit / quit")} Gracefully disconnect and exit`,
3063
+ `${chalk3.bold(".tables / .collections")} List all tables or collections with row counts`,
3064
+ `${chalk3.bold(".schema [name]")} Show columns, types, and indexes for a table`,
3065
+ `${chalk3.bold(".connect / .switch")} Switch database connection (shows saved connections)`,
3066
+ `${chalk3.bold(".model [name]")} Change active LLM model on the fly`,
3067
+ `${chalk3.bold(".provider [name]")} Switch LLM provider (google, openai, anthropic)`,
3068
+ `${chalk3.bold(".key [remove | set]")} View, remove, or update API key`,
3069
+ `${chalk3.bold(".chats")} List saved chat sessions`,
3070
+ `${chalk3.bold(".chat <id | num>")} Switch to another chat session`,
3071
+ `${chalk3.bold(".new")} Start a fresh chat session with clear memory`,
3072
+ `${chalk3.bold(".history")} Show conversation history for current chat`,
3073
+ `${chalk3.bold(".clear-chat")} Clear memory for current chat`,
3074
+ `${chalk3.bold(".md [file | text]")} Render markdown file or text in terminal`,
3075
+ `${chalk3.bold(".refresh")} Force refresh cached schema introspection`,
3076
+ `${chalk3.bold(".clear")} Clear terminal screen`,
3077
+ `${chalk3.bold(".help")} Display this command reference`,
3078
+ `${chalk3.bold("exit / quit")} Gracefully disconnect and exit`,
2254
3079
  "",
2255
- chalk2.bold.yellow("Example Natural Language Requests:"),
3080
+ chalk3.bold.yellow("Example Natural Language Requests:"),
2256
3081
  ' "Show top 10 users signed up in the last 30 days"',
3082
+ ' "Now count how many of them are active" (multi-turn follow-up)',
2257
3083
  ' "Calculate total revenue grouped by product category"',
2258
- ' "Add a concurrent index on orders(created_at)"',
2259
- ' "Delete users where status is inactive"'
3084
+ ' "Add a concurrent index on orders(created_at)"'
2260
3085
  ].join("\n"),
2261
3086
  {
2262
3087
  padding: 1,
@@ -2283,13 +3108,35 @@ Schema for collection: ${collection.name}`));
2283
3108
  } catch {
2284
3109
  }
2285
3110
  }
2286
- console.log(chalk2.cyan("\nDatabase connection closed. Goodbye! \u{1F44B}\n"));
3111
+ console.log(chalk3.cyan("\nDatabase connection closed. Goodbye! \u{1F44B}\n"));
2287
3112
  }
2288
3113
  };
2289
3114
 
2290
3115
  // src/cli.ts
2291
3116
  var program = new Command();
2292
- program.name("sandal-db").description("SANDAL - Safe Agentic Natural-language Database Access Layer").version("1.0.0").argument("[dbUrl]", "Database connection URL (PostgreSQL or MongoDB)").option("-p, --provider <provider>", "LLM provider: google | openai | anthropic").option("-m, --model <model>", "LLM model name (e.g. gemini-2.5-flash, gpt-4o, claude-3-5-sonnet-latest)").option("-k, --key <key>", "API key for the chosen LLM provider").option("--strict", "Strict safety mode: hard-blocks full database wipes (default: true)", true).option("--no-strict", "Disable strict safety mode").option("--allow-full-wipe", "Explicitly allow full database / all-table wipe queries with confirmation", false).option("--threshold <number>", "Row count threshold for classifying updates as dangerous", "50").action(async (cliDbUrl, options) => {
3117
+ program.name("sandal-db").description("SANDAL - Safe Agentic Natural-language Database Access Layer").version("1.0.0").argument("[dbUrl]", "Database connection URL (PostgreSQL or MongoDB)").option(
3118
+ "-p, --provider <provider>",
3119
+ "LLM provider: google | openai | anthropic"
3120
+ ).option(
3121
+ "-m, --model <model>",
3122
+ "LLM model name (e.g. gemini-2.5-flash, gpt-4o, claude-3-5-sonnet-latest)"
3123
+ ).option("-k, --key <key>", "API key for the chosen LLM provider").option(
3124
+ "--strict",
3125
+ "Strict safety mode: hard-blocks full database wipes (default: true)",
3126
+ true
3127
+ ).option("--no-strict", "Disable strict safety mode").option(
3128
+ "--allow-full-wipe",
3129
+ "Explicitly allow full database / all-table wipe queries with confirmation",
3130
+ false
3131
+ ).option(
3132
+ "--threshold <number>",
3133
+ "Row count threshold for classifying updates as dangerous",
3134
+ "50"
3135
+ ).option(
3136
+ "--markdown",
3137
+ "Render LLM answers formatted as terminal markdown (default: true)",
3138
+ true
3139
+ ).option("--no-markdown", "Disable terminal markdown rendering").action(async (cliDbUrl, options) => {
2293
3140
  try {
2294
3141
  await runCli({
2295
3142
  cliDbUrl,
@@ -2298,61 +3145,145 @@ program.name("sandal-db").description("SANDAL - Safe Agentic Natural-language Da
2298
3145
  cliApiKey: options.key,
2299
3146
  strictMode: options.strict !== false,
2300
3147
  allowFullWipe: Boolean(options.allowFullWipe),
2301
- rowThreshold: parseInt(options.threshold, 10) || 50
3148
+ rowThreshold: parseInt(options.threshold, 10) || 50,
3149
+ renderMarkdown: options.markdown !== false
2302
3150
  });
2303
3151
  } catch (err) {
2304
- console.error(chalk3.red(`
3152
+ console.error(chalk4.red(`
2305
3153
  Error: ${err.message}`));
2306
3154
  process.exit(1);
2307
3155
  }
2308
3156
  });
2309
- var configCmd = program.command("config").description("Manage stored database credentials and API keys in ~/.sandal/config.json").option("--set-db <url>", "Set default database connection URL").option("--set-key <key>", "Set API key for the default provider").option("--set-gemini <key>", "Set Google Gemini API key").option("--set-openai <key>", "Set OpenAI API key").option("--set-anthropic <key>", "Set Anthropic API key").option("--set-provider <provider>", "Set default LLM provider (google | openai | anthropic)").option("--set-model <model>", "Set default model name").option("--show", "Display current stored configuration (with masked secrets)").option("--path", "Print the path to the configuration file").action((opts) => {
3157
+ var configCmd = program.command("config").description(
3158
+ "Manage stored database credentials and API keys in ~/.sandal/config.json"
3159
+ ).option("--set-db <url>", "Set default database connection URL").option("--set-key <key>", "Set API key for the default provider").option("--set-gemini <key>", "Set Google Gemini API key").option("--set-openai <key>", "Set OpenAI API key").option("--set-anthropic <key>", "Set Anthropic API key").option(
3160
+ "--set-provider <provider>",
3161
+ "Set default LLM provider (google | openai | anthropic)"
3162
+ ).option("--set-model <model>", "Set default model name").option(
3163
+ "--set-markdown <boolean>",
3164
+ "Enable or disable default terminal markdown rendering (true | false)"
3165
+ ).option(
3166
+ "--remove-key [provider]",
3167
+ "Remove stored API key for a provider (google | openai | anthropic | all)"
3168
+ ).option(
3169
+ "--connections",
3170
+ "List all saved database connections"
3171
+ ).option(
3172
+ "--remove-connection <target>",
3173
+ "Remove a saved database connection by URL or index"
3174
+ ).option(
3175
+ "--show",
3176
+ "Display current stored configuration (with masked secrets)"
3177
+ ).option("--path", "Print the path to the configuration file").action((opts) => {
2310
3178
  if (opts.path) {
2311
3179
  console.log(getConfigFilePath());
2312
3180
  return;
2313
3181
  }
3182
+ if (opts.connections) {
3183
+ const list = getSavedConnections();
3184
+ if (list.length === 0) {
3185
+ console.log(chalk4.yellow("\nNo saved database connections found.\n"));
3186
+ } else {
3187
+ console.log(chalk4.bold.cyan("\nSaved Database Connections:"));
3188
+ list.forEach((url, i) => {
3189
+ console.log(` [${i + 1}] ${maskUrl(url)}`);
3190
+ });
3191
+ console.log("");
3192
+ }
3193
+ return;
3194
+ }
3195
+ if (opts.removeConnection) {
3196
+ const removed = removeSavedConnection(opts.removeConnection);
3197
+ if (removed) {
3198
+ console.log(chalk4.green(`Removed connection from saved history.`));
3199
+ } else {
3200
+ console.log(chalk4.red(`Connection "${opts.removeConnection}" not found in history.`));
3201
+ }
3202
+ return;
3203
+ }
3204
+ if (opts.removeKey !== void 0) {
3205
+ const prov = typeof opts.removeKey === "string" && opts.removeKey.trim() ? opts.removeKey.toLowerCase() : "all";
3206
+ removeApiKey(prov);
3207
+ console.log(chalk4.green(`Removed API key for "${prov}" from ~/.sandal/config.json.`));
3208
+ return;
3209
+ }
2314
3210
  if (opts.show) {
2315
3211
  const cfg = readConfig();
2316
- console.log(chalk3.bold.cyan("\nSaved Configuration (~/.sandal/config.json):"));
2317
- console.log(` Database URL: ${cfg.dbUrl ? chalk3.green(maskUrl(cfg.dbUrl)) : chalk3.gray("Not set")}`);
2318
- console.log(` Default Provider: ${cfg.defaultProvider ? chalk3.blue(cfg.defaultProvider) : chalk3.gray("Not set (defaults to google)")}`);
2319
- console.log(` Default Model: ${cfg.defaultModel ? chalk3.white(cfg.defaultModel) : chalk3.gray("Default for provider")}`);
2320
- console.log(` Gemini Key: ${cfg.geminiApiKey ? chalk3.yellow(maskApiKey(cfg.geminiApiKey)) : chalk3.gray("Not set")}`);
2321
- console.log(` OpenAI Key: ${cfg.openaiApiKey ? chalk3.yellow(maskApiKey(cfg.openaiApiKey)) : chalk3.gray("Not set")}`);
2322
- console.log(` Anthropic Key: ${cfg.anthropicApiKey ? chalk3.yellow(maskApiKey(cfg.anthropicApiKey)) : chalk3.gray("Not set")}
2323
- `);
3212
+ const saved = getSavedConnections();
3213
+ console.log(
3214
+ chalk4.bold.cyan("\nSaved Configuration (~/.sandal/config.json):")
3215
+ );
3216
+ console.log(
3217
+ ` Database URL: ${cfg.dbUrl ? chalk4.green(maskUrl(cfg.dbUrl)) : chalk4.gray("Not set")}`
3218
+ );
3219
+ console.log(
3220
+ ` Default Provider: ${cfg.defaultProvider ? chalk4.blue(cfg.defaultProvider) : chalk4.gray("Not set (defaults to google)")}`
3221
+ );
3222
+ console.log(
3223
+ ` Default Model: ${cfg.defaultModel ? chalk4.white(cfg.defaultModel) : chalk4.gray("Default for provider")}`
3224
+ );
3225
+ console.log(
3226
+ ` Markdown Rendering: ${cfg.renderMarkdown !== false ? chalk4.green("enabled") : chalk4.yellow("disabled")}`
3227
+ );
3228
+ console.log(
3229
+ ` Saved Connections: ${saved.length > 0 ? chalk4.cyan(`${saved.length} connection(s)`) : chalk4.gray("None")}`
3230
+ );
3231
+ console.log(
3232
+ ` Gemini Key: ${cfg.geminiApiKey ? chalk4.yellow(maskApiKey(cfg.geminiApiKey)) : chalk4.gray("Not set")}`
3233
+ );
3234
+ console.log(
3235
+ ` OpenAI Key: ${cfg.openaiApiKey ? chalk4.yellow(maskApiKey(cfg.openaiApiKey)) : chalk4.gray("Not set")}`
3236
+ );
3237
+ console.log(
3238
+ ` Anthropic Key: ${cfg.anthropicApiKey ? chalk4.yellow(maskApiKey(cfg.anthropicApiKey)) : chalk4.gray("Not set")}
3239
+ `
3240
+ );
2324
3241
  return;
2325
3242
  }
2326
3243
  const updates = {};
2327
3244
  if (opts.setDb) {
2328
3245
  detectDatabaseType(opts.setDb);
2329
3246
  updates.dbUrl = opts.setDb;
2330
- console.log(chalk3.green(`Updated default database URL: ${maskUrl(opts.setDb)}`));
3247
+ addSavedConnection(opts.setDb);
3248
+ console.log(
3249
+ chalk4.green(`Updated default database URL: ${maskUrl(opts.setDb)}`)
3250
+ );
2331
3251
  }
2332
3252
  if (opts.setProvider) {
2333
3253
  const p = opts.setProvider.toLowerCase();
2334
3254
  if (!["google", "openai", "anthropic"].includes(p)) {
2335
- console.error(chalk3.red("Invalid provider. Expected google, openai, or anthropic."));
3255
+ console.error(
3256
+ chalk4.red("Invalid provider. Expected google, openai, or anthropic.")
3257
+ );
2336
3258
  process.exit(1);
2337
3259
  }
2338
3260
  updates.defaultProvider = p;
2339
- console.log(chalk3.green(`Updated default provider: ${p}`));
3261
+ console.log(chalk4.green(`Updated default provider: ${p}`));
2340
3262
  }
2341
3263
  if (opts.setModel) {
2342
3264
  updates.defaultModel = opts.setModel;
2343
- console.log(chalk3.green(`Updated default model: ${opts.setModel}`));
3265
+ console.log(chalk4.green(`Updated default model: ${opts.setModel}`));
3266
+ }
3267
+ if (opts.setMarkdown !== void 0) {
3268
+ const val = opts.setMarkdown.toLowerCase() === "true" || opts.setMarkdown === "1" || opts.setMarkdown === "yes";
3269
+ updates.renderMarkdown = val;
3270
+ console.log(
3271
+ chalk4.green(
3272
+ `Updated default markdown rendering: ${val ? "enabled" : "disabled"}.`
3273
+ )
3274
+ );
2344
3275
  }
2345
3276
  if (opts.setGemini) {
2346
3277
  updates.geminiApiKey = opts.setGemini;
2347
- console.log(chalk3.green("Updated Gemini API key."));
3278
+ console.log(chalk4.green("Updated Gemini API key."));
2348
3279
  }
2349
3280
  if (opts.setOpenai) {
2350
3281
  updates.openaiApiKey = opts.setOpenai;
2351
- console.log(chalk3.green("Updated OpenAI API key."));
3282
+ console.log(chalk4.green("Updated OpenAI API key."));
2352
3283
  }
2353
3284
  if (opts.setAnthropic) {
2354
3285
  updates.anthropicApiKey = opts.setAnthropic;
2355
- console.log(chalk3.green("Updated Anthropic API key."));
3286
+ console.log(chalk4.green("Updated Anthropic API key."));
2356
3287
  }
2357
3288
  if (opts.setKey) {
2358
3289
  const current = readConfig();
@@ -2360,16 +3291,53 @@ var configCmd = program.command("config").description("Manage stored database cr
2360
3291
  if (prov === "google") updates.geminiApiKey = opts.setKey;
2361
3292
  else if (prov === "openai") updates.openaiApiKey = opts.setKey;
2362
3293
  else if (prov === "anthropic") updates.anthropicApiKey = opts.setKey;
2363
- console.log(chalk3.green(`Updated API key for provider "${prov}".`));
3294
+ console.log(chalk4.green(`Updated API key for provider "${prov}".`));
2364
3295
  }
2365
3296
  if (Object.keys(updates).length > 0) {
2366
3297
  writeConfig(updates);
2367
- console.log(chalk3.gray(`Saved to ${getConfigFilePath()}`));
3298
+ console.log(chalk4.gray(`Saved to ${getConfigFilePath()}`));
2368
3299
  } else {
2369
3300
  configCmd.help();
2370
3301
  }
2371
3302
  });
3303
+ program.command("markdown [fileOrText]").alias("md").description("Render markdown text or a markdown file directly in the terminal").option("-w, --width <width>", "Wrap width in columns").option("--no-prefix", "Hide heading section prefixes").action(async (fileOrText, opts) => {
3304
+ let content = fileOrText;
3305
+ if (!content) {
3306
+ if (!process.stdin.isTTY) {
3307
+ const chunks = [];
3308
+ for await (const chunk of process.stdin) {
3309
+ chunks.push(Buffer.from(chunk));
3310
+ }
3311
+ content = Buffer.concat(chunks).toString("utf-8");
3312
+ } else {
3313
+ console.log(
3314
+ chalk4.yellow(
3315
+ "\nUsage: sandal md <file | text> or pipe markdown to sandal md"
3316
+ )
3317
+ );
3318
+ console.log(chalk4.gray("Example: sandal md README.md"));
3319
+ console.log(chalk4.gray('Example: sandal md "# Hello World"'));
3320
+ console.log(chalk4.gray("Example: cat notes.md | sandal md\n"));
3321
+ return;
3322
+ }
3323
+ } else {
3324
+ try {
3325
+ if (fs4.existsSync(content) && fs4.statSync(content).isFile()) {
3326
+ content = fs4.readFileSync(content, "utf-8");
3327
+ }
3328
+ } catch {
3329
+ }
3330
+ }
3331
+ const width = opts.width ? parseInt(opts.width, 10) : void 0;
3332
+ const rendered = renderMarkdown(content, {
3333
+ width,
3334
+ showSectionPrefix: opts.prefix !== false
3335
+ });
3336
+ console.log("\n" + rendered + "\n");
3337
+ });
2372
3338
  async function runCli(opts) {
3339
+ const config = readConfig();
3340
+ const shouldRenderMarkdown = opts.renderMarkdown !== void 0 ? opts.renderMarkdown : config.renderMarkdown !== false;
2373
3341
  let resolved = resolveCredentials({
2374
3342
  cliDbUrl: opts.cliDbUrl,
2375
3343
  cliProvider: opts.cliProvider,
@@ -2378,7 +3346,11 @@ async function runCli(opts) {
2378
3346
  });
2379
3347
  let dbUrl = resolved.dbUrl;
2380
3348
  if (!dbUrl) {
2381
- console.log(chalk3.yellow("\nNo database connection URL found in CLI flags, environment, or config."));
3349
+ console.log(
3350
+ chalk4.yellow(
3351
+ "\nNo database connection URL found in CLI flags, environment, or config."
3352
+ )
3353
+ );
2382
3354
  dbUrl = await input2({
2383
3355
  message: "Enter your database URL (postgres://... or mongodb://...):",
2384
3356
  validate: (val) => {
@@ -2406,7 +3378,7 @@ async function runCli(opts) {
2406
3378
  let provider = resolved.provider;
2407
3379
  let apiKey = resolved.apiKey;
2408
3380
  if (!apiKey) {
2409
- console.log(chalk3.yellow("\nNo API key found in environment or config."));
3381
+ console.log(chalk4.yellow("\nNo API key found in environment or config."));
2410
3382
  provider = await select({
2411
3383
  message: "Select your LLM provider:",
2412
3384
  choices: [
@@ -2424,19 +3396,29 @@ async function runCli(opts) {
2424
3396
  default: true
2425
3397
  });
2426
3398
  if (saveKey) {
2427
- if (provider === "google") writeConfig({ geminiApiKey: apiKey, defaultProvider: "google" });
2428
- else if (provider === "openai") writeConfig({ openaiApiKey: apiKey, defaultProvider: "openai" });
2429
- else if (provider === "anthropic") writeConfig({ anthropicApiKey: apiKey, defaultProvider: "anthropic" });
3399
+ if (provider === "google")
3400
+ writeConfig({ geminiApiKey: apiKey, defaultProvider: "google" });
3401
+ else if (provider === "openai")
3402
+ writeConfig({ openaiApiKey: apiKey, defaultProvider: "openai" });
3403
+ else if (provider === "anthropic")
3404
+ writeConfig({ anthropicApiKey: apiKey, defaultProvider: "anthropic" });
2430
3405
  }
2431
3406
  }
2432
3407
  const modelName = opts.cliModel || resolved.model || getDefaultModelForProvider(provider);
2433
- const dbSpinner = ora2(`Connecting to database (${maskUrl(dbUrl)})...`).start();
3408
+ const dbSpinner = ora2(
3409
+ `Connecting to database (${maskUrl(dbUrl)})...`
3410
+ ).start();
2434
3411
  const adapter = createDatabaseAdapter(dbUrl);
2435
3412
  try {
2436
3413
  await adapter.connect();
2437
- dbSpinner.succeed(chalk3.green(`Connected to ${adapter.type.toUpperCase()} database: ${adapter.databaseName}`));
3414
+ dbSpinner.succeed(
3415
+ chalk4.green(
3416
+ `Connected to ${adapter.type.toUpperCase()} database: ${adapter.databaseName}`
3417
+ )
3418
+ );
3419
+ addSavedConnection(dbUrl);
2438
3420
  } catch (err) {
2439
- dbSpinner.fail(chalk3.red(`Failed to connect to database: ${err.message}`));
3421
+ dbSpinner.fail(chalk4.red(`Failed to connect to database: ${err.message}`));
2440
3422
  process.exit(1);
2441
3423
  }
2442
3424
  const model = createChatModel({
@@ -2449,9 +3431,11 @@ async function runCli(opts) {
2449
3431
  model,
2450
3432
  provider,
2451
3433
  modelName,
3434
+ apiKey,
2452
3435
  strictMode: opts.strictMode,
2453
3436
  allowFullWipe: opts.allowFullWipe,
2454
- rowThreshold: opts.rowThreshold
3437
+ rowThreshold: opts.rowThreshold,
3438
+ renderMarkdown: shouldRenderMarkdown
2455
3439
  });
2456
3440
  await repl.start();
2457
3441
  }