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.
- package/README.md +109 -0
- package/dist/cli.js +1091 -107
- 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
|
|
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
|
|
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(
|
|
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([
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
2381
|
+
const spinner = ora(chalk3.blue("Refreshing schema cache...")).start();
|
|
2033
2382
|
try {
|
|
2034
2383
|
await this.adapter.inspectSchema(true);
|
|
2035
|
-
spinner.succeed(
|
|
2384
|
+
spinner.succeed(chalk3.green("Schema refreshed successfully."));
|
|
2036
2385
|
} catch (err) {
|
|
2037
|
-
spinner.fail(
|
|
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
|
-
|
|
2042
|
-
|
|
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
|
-
`${
|
|
2447
|
+
`${chalk3.bold.red("\u{1F6E1}\uFE0F GUARDRAIL ENFORCEMENT")}
|
|
2048
2448
|
|
|
2049
|
-
${
|
|
2449
|
+
${chalk3.yellow(REFUSAL_MESSAGE)}
|
|
2050
2450
|
|
|
2051
|
-
${
|
|
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(
|
|
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" +
|
|
2082
|
-
|
|
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(
|
|
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(
|
|
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) =>
|
|
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
|
|
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(
|
|
2931
|
+
console.log(chalk3.yellow(`Showing first 25 of ${rows.length.toLocaleString()} rows.`));
|
|
2123
2932
|
}
|
|
2124
2933
|
console.log(
|
|
2125
|
-
|
|
2126
|
-
`Total rows: ${queryResult.rowCount ?? rows.length} |
|
|
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(
|
|
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(
|
|
2948
|
+
console.log(chalk3.yellow("No tables found in public schema."));
|
|
2139
2949
|
return;
|
|
2140
2950
|
}
|
|
2141
|
-
console.log(
|
|
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 ${
|
|
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(
|
|
2962
|
+
console.log(chalk3.yellow("No collections found."));
|
|
2153
2963
|
return;
|
|
2154
2964
|
}
|
|
2155
|
-
console.log(
|
|
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 ${
|
|
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(
|
|
2975
|
+
spinner.fail(chalk3.red(`Failed to list entities: ${err.message}`));
|
|
2166
2976
|
}
|
|
2167
2977
|
}
|
|
2168
2978
|
async handleShowSchema(entityName) {
|
|
2169
|
-
const spinner = ora(
|
|
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
|
|
2179
|
-
(t) => 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(
|
|
2992
|
+
console.log(chalk3.red(`Table "${entityName}" not found.`));
|
|
2183
2993
|
return;
|
|
2184
2994
|
}
|
|
2185
|
-
console.log(
|
|
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", "
|
|
2998
|
+
head: ["Column", "Data Type", "Nullable", "Primary Key", "Default"].map(
|
|
2999
|
+
(h) => chalk3.cyan.bold(h)
|
|
3000
|
+
)
|
|
2189
3001
|
});
|
|
2190
|
-
for (const
|
|
3002
|
+
for (const c of table.columns) {
|
|
2191
3003
|
tableDetails.push([
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
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
|
|
3013
|
+
const collection = (schema.collections || []).find(
|
|
3014
|
+
(c) => c.name.toLowerCase() === entityName.toLowerCase()
|
|
3015
|
+
);
|
|
2202
3016
|
if (!collection) {
|
|
2203
|
-
console.log(
|
|
3017
|
+
console.log(chalk3.red(`Collection "${entityName}" not found.`));
|
|
2204
3018
|
return;
|
|
2205
3019
|
}
|
|
2206
|
-
console.log(
|
|
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) =>
|
|
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(
|
|
3031
|
+
spinner.fail(chalk3.red(`Failed to inspect schema: ${err.message}`));
|
|
2218
3032
|
}
|
|
2219
3033
|
}
|
|
2220
3034
|
printWelcomeBanner() {
|
|
2221
3035
|
const banner = [
|
|
2222
|
-
|
|
2223
|
-
|
|
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
|
-
`${
|
|
2226
|
-
`${
|
|
2227
|
-
`${
|
|
2228
|
-
`${
|
|
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
|
-
|
|
2231
|
-
|
|
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
|
-
|
|
3061
|
+
chalk3.bold.cyan("SANDAL REPL Commands:"),
|
|
2247
3062
|
"",
|
|
2248
|
-
`${
|
|
2249
|
-
`${
|
|
2250
|
-
`${
|
|
2251
|
-
`${
|
|
2252
|
-
`${
|
|
2253
|
-
`${
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
2317
|
-
console.log(
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
console.log(
|
|
2321
|
-
|
|
2322
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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")
|
|
2428
|
-
|
|
2429
|
-
else if (provider === "
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
}
|