@deepsql/mcp 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -1
- package/README.md +37 -1
- package/agent-profile/SOUL.md +28 -0
- package/agent-profile/distribution.yaml +41 -0
- package/agent-profile/skills/bi-query/SKILL.md +40 -0
- package/agent-profile/skills/index-advisor/SKILL.md +34 -0
- package/agent-profile/skills/schema-exploration/SKILL.md +32 -0
- package/agent-profile/skills/slow-query-optimize/SKILL.md +31 -0
- package/agent-profile/skills/workload-analysis/SKILL.md +36 -0
- package/agent-profile/skins/deepsql.yaml +46 -0
- package/agent-profile/tui/HERMES_VERSION +1 -0
- package/agent-profile/tui/dist/entry.js +92903 -0
- package/deepsql-phase1-lib.js +10 -4
- package/package.json +6 -3
- package/scripts/postinstall.js +57 -0
- package/skills/SKILL_BODY.md +3 -2
- package/src/agent/bootstrap.js +293 -0
- package/src/cli.js +36 -4
- package/src/cli.test.js +8 -0
- package/src/commands/agent.js +47 -0
- package/src/commands/connections.js +7 -4
- package/src/commands/connections.test.js +93 -0
- package/src/commands/slow-queries.js +2 -2
package/deepsql-phase1-lib.js
CHANGED
|
@@ -350,9 +350,11 @@ const TOOL_DEFINITIONS = [
|
|
|
350
350
|
{
|
|
351
351
|
name: "optimize_slow_query",
|
|
352
352
|
description:
|
|
353
|
-
"Get AI
|
|
354
|
-
+ "
|
|
355
|
-
+ "the
|
|
353
|
+
"Get an AI query REWRITE and plan diagnosis for one specific slow query. "
|
|
354
|
+
+ "Single-query scoped: returns a rewritten SQL (validated against the live DB), "
|
|
355
|
+
+ "the plan bottleneck, and an estimated improvement. Does NOT recommend indexes — "
|
|
356
|
+
+ "index and pre-aggregation recommendations require the whole workload and come "
|
|
357
|
+
+ "from the holistic Workload Analysis (and the get_index_recommendations tool). "
|
|
356
358
|
+ "Pass `avgExecutionTimeMs` to anchor the impact estimate to a real baseline.",
|
|
357
359
|
inputSchema: {
|
|
358
360
|
type: "object",
|
|
@@ -1570,7 +1572,11 @@ function buildToolResult(name, payload, extra = {}) {
|
|
|
1570
1572
|
text: summary,
|
|
1571
1573
|
},
|
|
1572
1574
|
],
|
|
1573
|
-
structuredContent
|
|
1575
|
+
// MCP spec requires `structuredContent` to be a JSON object. Several tools
|
|
1576
|
+
// (list_connections, get_relationships, list_business_rules, …) return a
|
|
1577
|
+
// top-level array from the backend; wrap those so spec-strict clients
|
|
1578
|
+
// (e.g. the `mcp` Python SDK used by Hermes) don't reject the result.
|
|
1579
|
+
structuredContent: Array.isArray(payload) ? { items: payload } : payload,
|
|
1574
1580
|
};
|
|
1575
1581
|
}
|
|
1576
1582
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepsql/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DeepSQL CLI and stdio MCP server for self-hosted deployments",
|
|
3
|
+
"version": "0.21.0",
|
|
4
|
+
"description": "DeepSQL CLI, DBA Agent TUI, and stdio MCP server for self-hosted deployments",
|
|
5
5
|
"bin": {
|
|
6
6
|
"deepsql": "bin/deepsql.js",
|
|
7
7
|
"deepsql-mcp": "deepsql-phase1-server.js"
|
|
@@ -14,13 +14,16 @@
|
|
|
14
14
|
"bin",
|
|
15
15
|
"skills",
|
|
16
16
|
"src",
|
|
17
|
+
"scripts",
|
|
18
|
+
"agent-profile",
|
|
17
19
|
"deepsql-phase1-server.js",
|
|
18
20
|
"deepsql-phase1-lib.js",
|
|
19
21
|
"claude_desktop_config.customer.example.json",
|
|
20
22
|
"codex_config.customer.example.toml"
|
|
21
23
|
],
|
|
22
24
|
"scripts": {
|
|
23
|
-
"test": "node --test deepsql-phase1-lib.test.js src/*.test.js src/**/*.test.js"
|
|
25
|
+
"test": "node --test deepsql-phase1-lib.test.js src/*.test.js src/**/*.test.js",
|
|
26
|
+
"postinstall": "node scripts/postinstall.js"
|
|
24
27
|
},
|
|
25
28
|
"engines": {
|
|
26
29
|
"node": ">=20"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// Postinstall: pre-install the DeepSQL Agent (Hermes) runtime at `npm install -g`
|
|
5
|
+
// time so the first `deepsql` launch is instant instead of triggering a heavy
|
|
6
|
+
// one-time download. Best-effort and NON-FATAL — it never fails the npm install.
|
|
7
|
+
//
|
|
8
|
+
// Opt out (e.g. pure-MCP users who only want the editor tools, not the agent):
|
|
9
|
+
// DEEPSQL_SKIP_AGENT_SETUP=1 npm install -g @deepsql/mcp
|
|
10
|
+
//
|
|
11
|
+
// It auto-skips when there's nothing/no-one to set up for:
|
|
12
|
+
// - CI environments (process.env.CI)
|
|
13
|
+
// - non-global installs (local dev `npm install`, dependency installs)
|
|
14
|
+
// The profile itself is provisioned later, on first `deepsql` after login,
|
|
15
|
+
// because it needs the user's token.
|
|
16
|
+
|
|
17
|
+
function log(msg) {
|
|
18
|
+
process.stdout.write(`${msg}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function skip(reason) {
|
|
22
|
+
// Silent for the common "not global / CI" cases; only note an explicit opt-out.
|
|
23
|
+
if (reason) log(`DeepSQL Agent: ${reason}`);
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
if (process.env.DEEPSQL_SKIP_AGENT_SETUP) {
|
|
29
|
+
skip("agent runtime setup skipped (DEEPSQL_SKIP_AGENT_SETUP set).");
|
|
30
|
+
}
|
|
31
|
+
if (process.env.CI) skip();
|
|
32
|
+
// Only pre-install on an explicit global install (or a forced run). Local /
|
|
33
|
+
// dependency installs shouldn't pull a Python+Node runtime.
|
|
34
|
+
const isGlobal = String(process.env.npm_config_global || "") === "true";
|
|
35
|
+
if (!isGlobal && !process.env.DEEPSQL_FORCE_AGENT_SETUP) skip();
|
|
36
|
+
|
|
37
|
+
const { ensureHermes, hermesInstalled } = require("../src/agent/bootstrap");
|
|
38
|
+
|
|
39
|
+
if (hermesInstalled()) {
|
|
40
|
+
log("✓ DeepSQL Agent runtime already present.");
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
log("DeepSQL Agent: pre-installing the agent runtime so `deepsql` starts instantly.");
|
|
45
|
+
log(" (Opt out next time with DEEPSQL_SKIP_AGENT_SETUP=1.)");
|
|
46
|
+
const ok = ensureHermes({ stderr: process.stdout });
|
|
47
|
+
if (ok) {
|
|
48
|
+
log("✓ DeepSQL Agent runtime ready. Run `deepsql login`, then `deepsql`.");
|
|
49
|
+
} else {
|
|
50
|
+
// Non-fatal: the lazy installer will retry on first `deepsql agent`.
|
|
51
|
+
log("DeepSQL Agent: runtime not pre-installed; it'll be set up on first `deepsql` run.");
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// Never break the npm install over agent pre-setup.
|
|
55
|
+
log(`DeepSQL Agent: skipped runtime pre-install (${e && e.message ? e.message : e}).`);
|
|
56
|
+
}
|
|
57
|
+
process.exit(0);
|
package/skills/SKILL_BODY.md
CHANGED
|
@@ -98,7 +98,7 @@ usually doesn't know about either; that's exactly why DeepSQL exists.
|
|
|
98
98
|
| `get_slow_query_customers(connectionId)` | Tenants ranked by total slow-query time — answers "which customer is driving the load?" |
|
|
99
99
|
| `get_query_samples(connectionId, fingerprint)` | Literal SQL samples with bind values for a fingerprint, slowest-first. Use to reproduce an execution or run a real EXPLAIN. |
|
|
100
100
|
| `get_slow_query_insights(connectionId, kind?, window?, limit?)` | Pre-computed AI insights — `hotspots`, `remediation`, `tail-risk`, `plan-drift`, `skew`, or `all` (default). |
|
|
101
|
-
| `optimize_slow_query(connectionId, queryText, avgExecutionTimeMs?)` | AI
|
|
101
|
+
| `optimize_slow_query(connectionId, queryText, avgExecutionTimeMs?)` | AI query REWRITE + plan diagnosis for one SQL (single-query scoped). NOT indexes — those need the whole workload; use `get_index_recommendations` or Workload Analysis. |
|
|
102
102
|
| `get_table_growth(connectionId, tableName?, days?)` | Persistent stats history: per-table size/row time series + headline rollups. Use to answer "which tables are growing fastest?" or "how much has X grown in the last month?" without scanning the live DB. |
|
|
103
103
|
| `get_growth_anomalies(connectionId, tableName?, unacknowledgedOnly?, days?)` | DeepSQL-flagged sudden growth spikes with severity (CRITICAL/WARNING/INFO), anomaly type, before/after sizes, confidence score. Check this BEFORE walking the user through a slow-query plan — a recent growth anomaly is often the real root cause. |
|
|
104
104
|
| `execute_sql(connectionId, query, ...)` | Run any SQL — SELECT for everyone, DML/DDL for admins (two-step confirm). |
|
|
@@ -138,10 +138,11 @@ called by claude-code via deepsql CLI").
|
|
|
138
138
|
deepsql query "SELECT 1" --connection prod-pg --caller-agent claude-code --json
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
-
### Command catalog (
|
|
141
|
+
### Command catalog (20 top-level commands)
|
|
142
142
|
|
|
143
143
|
| Command | What it does | MCP equivalent? |
|
|
144
144
|
|---|---|---|
|
|
145
|
+
| `deepsql agent` (or bare `deepsql` in a terminal) | Launch the **DeepSQL Agent** — an interactive DBA/BI chat TUI. Uses your saved `deepsql login`; the model is proxied by the DeepSQL backend, so no LLM key is needed. First run installs the agent runtime. | none — interactive TUI |
|
|
145
146
|
| `deepsql login` | Authorize CLI against a DeepSQL host (browser PKCE / device code / password) | none — interactive only |
|
|
146
147
|
| `deepsql logout` | Revoke the saved token | none |
|
|
147
148
|
| `deepsql whoami` | Show the logged-in user, role, URL, pinned connection | none |
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Lazy bootstrap for the DeepSQL Agent (Hermes-based TUI): detect/install the
|
|
4
|
+
// Hermes runtime and provision the `deepsql` profile (model → the backend LLM
|
|
5
|
+
// proxy with the user's token; DeepSQL MCP tools; DBA persona/skills; subtle
|
|
6
|
+
// DeepSQL skin; read-only sandbox). Provisioning is idempotent; the per-launch
|
|
7
|
+
// config rewrite keeps the token/connection fresh.
|
|
8
|
+
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const os = require("node:os");
|
|
11
|
+
const path = require("node:path");
|
|
12
|
+
const { spawnSync } = require("node:child_process");
|
|
13
|
+
const { userHome } = require("../user-home");
|
|
14
|
+
|
|
15
|
+
const HOME = userHome();
|
|
16
|
+
const HERMES_HOME = path.join(HOME, ".hermes");
|
|
17
|
+
const AGENT_DIR = path.join(HERMES_HOME, "hermes-agent");
|
|
18
|
+
const PROFILE = "deepsql";
|
|
19
|
+
const PROFILE_HOME = path.join(HERMES_HOME, "profiles", PROFILE);
|
|
20
|
+
|
|
21
|
+
const INSTALL_URL = "https://hermes-agent.nousresearch.com/install.sh";
|
|
22
|
+
// Pin the Hermes runtime to the exact commit our bundled TUI (agent-profile/
|
|
23
|
+
// tui/entry.js, HERMES_VERSION 0.17.0) was built against. This makes installs
|
|
24
|
+
// reproducible AND guarantees the branded-TUI overlay's version guard matches,
|
|
25
|
+
// so end users get the DeepSQL wordmark/welcome — not a "latest main" build
|
|
26
|
+
// that drifts in behavior and skips our overlay. Bump alongside the bundle.
|
|
27
|
+
const HERMES_COMMIT = "92d40c2553961243991376bf889d833e8326caf7";
|
|
28
|
+
|
|
29
|
+
// Extra dirs the Hermes runtime + its installer rely on but a bare GUI/login
|
|
30
|
+
// shell PATH (as seen by a spawned Node process) often lacks: the venv, the
|
|
31
|
+
// installer's `hermes` symlink targets, and Homebrew (uv/node/git).
|
|
32
|
+
function extraPathDirs() {
|
|
33
|
+
return [
|
|
34
|
+
path.join(AGENT_DIR, ".venv", "bin"),
|
|
35
|
+
path.join(HOME, ".local", "bin"),
|
|
36
|
+
"/usr/local/bin",
|
|
37
|
+
"/opt/homebrew/bin",
|
|
38
|
+
"/usr/local/lib/hermes-agent/.venv/bin",
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Env for any spawn that runs (or installs) Hermes: augmented PATH so node/uv/
|
|
43
|
+
// git resolve, UV_NO_CONFIG so uv ignores stray project configs, and a pinned
|
|
44
|
+
// HERMES_HOME so the install/profile lands where we expect.
|
|
45
|
+
function hermesEnv(extra = {}) {
|
|
46
|
+
const sep = path.delimiter;
|
|
47
|
+
const have = (process.env.PATH || "").split(sep);
|
|
48
|
+
const merged = [...extraPathDirs().filter((d) => !have.includes(d)), ...have].join(sep);
|
|
49
|
+
return { ...process.env, PATH: merged, UV_NO_CONFIG: "1", HERMES_HOME, ...extra };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// All the places the official installer may drop a runnable `hermes` for a
|
|
53
|
+
// non-root install (venv) or an FHS/root install (/usr/local), plus the PATH.
|
|
54
|
+
function findHermes() {
|
|
55
|
+
const candidates = [];
|
|
56
|
+
if (process.env.HERMES_INSTALL_DIR) {
|
|
57
|
+
candidates.push(path.join(process.env.HERMES_INSTALL_DIR, ".venv", "bin", "hermes"));
|
|
58
|
+
}
|
|
59
|
+
candidates.push(
|
|
60
|
+
path.join(AGENT_DIR, ".venv", "bin", "hermes"),
|
|
61
|
+
"/usr/local/lib/hermes-agent/.venv/bin/hermes",
|
|
62
|
+
path.join(HOME, ".local", "bin", "hermes"),
|
|
63
|
+
"/usr/local/bin/hermes"
|
|
64
|
+
);
|
|
65
|
+
for (const bin of candidates) {
|
|
66
|
+
if (!fs.existsSync(bin)) continue;
|
|
67
|
+
if (spawnSync(bin, ["--version"], { stdio: "ignore", env: hermesEnv() }).status === 0) return bin;
|
|
68
|
+
}
|
|
69
|
+
// Last resort: a `hermes` already on the (augmented) PATH.
|
|
70
|
+
if (spawnSync("hermes", ["--version"], { stdio: "ignore", env: hermesEnv() }).status === 0) return "hermes";
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hermesBin() {
|
|
75
|
+
return findHermes() || path.join(AGENT_DIR, ".venv", "bin", "hermes");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hermesInstalled() {
|
|
79
|
+
return findHermes() !== null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function mcpServerPath() {
|
|
83
|
+
return path.resolve(__dirname, "..", "..", "deepsql-phase1-server.js");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Profile distribution (SOUL.md + skills/ + skins/): the bundled copy shipped in
|
|
87
|
+
// the package, else the repo `hermes/` dir during local development.
|
|
88
|
+
function distSource() {
|
|
89
|
+
const bundled = path.resolve(__dirname, "..", "..", "agent-profile");
|
|
90
|
+
if (fs.existsSync(path.join(bundled, "distribution.yaml"))) return bundled;
|
|
91
|
+
const repoHermes = path.resolve(__dirname, "..", "..", "..", "hermes");
|
|
92
|
+
if (fs.existsSync(path.join(repoHermes, "distribution.yaml"))) return repoHermes;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Install the Hermes runtime, pinned + non-interactive, with the output teed to
|
|
97
|
+
// a log so a failure surfaces a real cause instead of a vague "can't set up".
|
|
98
|
+
// `quiet` (postinstall) hides the live stream; interactive first-run shows it.
|
|
99
|
+
function ensureHermes(io, { quiet = false } = {}) {
|
|
100
|
+
const err = (io && io.stderr) || process.stderr;
|
|
101
|
+
if (hermesInstalled()) return true;
|
|
102
|
+
|
|
103
|
+
err.write("DeepSQL Agent: installing the agent runtime (first run; ~1–2 min)…\n");
|
|
104
|
+
const logFile = path.join(os.tmpdir(), `deepsql-agent-install-${process.pid}.log`);
|
|
105
|
+
// --commit pins the checkout; --skip-setup/--non-interactive avoid the
|
|
106
|
+
// wizard + any TTY prompt. tee keeps a persisted log for diagnostics.
|
|
107
|
+
const script =
|
|
108
|
+
`curl -fsSL ${INSTALL_URL} | bash -s -- ` +
|
|
109
|
+
`--skip-setup --non-interactive --commit ${HERMES_COMMIT} 2>&1 | tee ${JSON.stringify(logFile)}`;
|
|
110
|
+
const r = spawnSync("bash", ["-lc", script], {
|
|
111
|
+
stdio: quiet ? ["ignore", "ignore", "ignore"] : "inherit",
|
|
112
|
+
env: hermesEnv(),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (hermesInstalled()) {
|
|
116
|
+
err.write("✓ DeepSQL Agent runtime installed. (Ignore any \"run hermes setup\" note above — DeepSQL configures it for you.)\n");
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
err.write(
|
|
120
|
+
"DeepSQL Agent: the runtime install did not complete.\n" +
|
|
121
|
+
` Install log: ${logFile}\n` +
|
|
122
|
+
` Exit code: ${r.status == null ? "n/a" : r.status}\n` +
|
|
123
|
+
" You can retry, or install Hermes manually: " +
|
|
124
|
+
`curl -fsSL ${INSTALL_URL} | bash -s -- --commit ${HERMES_COMMIT}\n`
|
|
125
|
+
);
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function q(s) {
|
|
130
|
+
return `"${String(s).replace(/"/g, '\\"')}"`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function writeRuntimeConfig(session) {
|
|
134
|
+
fs.mkdirSync(PROFILE_HOME, { recursive: true });
|
|
135
|
+
const base = session.baseUrl.replace(/\/?$/, "/"); // ensure single trailing /
|
|
136
|
+
const proxy = base + "api/llm/v1";
|
|
137
|
+
const apiBase = base + "api/";
|
|
138
|
+
const token = session.token;
|
|
139
|
+
const mcp = mcpServerPath();
|
|
140
|
+
const conn = session.defaultConnection || "";
|
|
141
|
+
|
|
142
|
+
const config =
|
|
143
|
+
`model:
|
|
144
|
+
default: gpt-5.4
|
|
145
|
+
provider: custom
|
|
146
|
+
base_url: ${q(proxy)}
|
|
147
|
+
api_key: ${q(token)}
|
|
148
|
+
api_mode: chat_completions
|
|
149
|
+
context_length: 272000
|
|
150
|
+
mcp_servers:
|
|
151
|
+
deepsql:
|
|
152
|
+
command: node
|
|
153
|
+
args:
|
|
154
|
+
- ${q(mcp)}
|
|
155
|
+
env:
|
|
156
|
+
DEEPSQL_API_BASE_URL: ${q(apiBase)}
|
|
157
|
+
DEEPSQL_MCP_USER_ID: deepsql-cli
|
|
158
|
+
DEEPSQL_AUTH_TOKEN: ${q(token)}
|
|
159
|
+
approvals:
|
|
160
|
+
mode: smart
|
|
161
|
+
display:
|
|
162
|
+
skin: deepsql
|
|
163
|
+
interface: tui
|
|
164
|
+
`;
|
|
165
|
+
fs.writeFileSync(path.join(PROFILE_HOME, "config.yaml"), config);
|
|
166
|
+
|
|
167
|
+
// OPENAI_BASE_URL/OPENAI_API_KEY are belt-and-suspenders: Hermes' first-run
|
|
168
|
+
// guard (_has_any_provider_configured) treats either as "a provider is
|
|
169
|
+
// configured", so the "run hermes setup" gate can never fire on a fresh
|
|
170
|
+
// machine even if config-load timing differs. They point at the SAME backend
|
|
171
|
+
// proxy as the model block, authed by the user's DeepSQL token.
|
|
172
|
+
const env =
|
|
173
|
+
`DEEPSQL_API_BASE_URL=${apiBase}
|
|
174
|
+
DEEPSQL_AUTH_TOKEN=${token}
|
|
175
|
+
DEEPSQL_MCP_SERVER=${mcp}
|
|
176
|
+
DEEPSQL_DEFAULT_CONNECTION_ID=${conn}
|
|
177
|
+
OPENAI_BASE_URL=${proxy}
|
|
178
|
+
OPENAI_API_KEY=${token}
|
|
179
|
+
HERMES_REVISION=deepsql-cli
|
|
180
|
+
`;
|
|
181
|
+
fs.writeFileSync(path.join(PROFILE_HOME, ".env"), env, { mode: 0o600 });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Env for spawning the TUI: augmented PATH (so the TUI's node + the MCP
|
|
185
|
+
// server's node resolve), the provider vars that defeat Hermes' first-run
|
|
186
|
+
// guard regardless of config state, and HERMES_TUI_DIR pointing at our prebuilt
|
|
187
|
+
// branded TUI (when the Hermes version matches) so Hermes runs it directly with
|
|
188
|
+
// no esbuild — branded on the first launch.
|
|
189
|
+
function launchEnv(session) {
|
|
190
|
+
const base = session.baseUrl.replace(/\/?$/, "/");
|
|
191
|
+
const extra = {
|
|
192
|
+
OPENAI_BASE_URL: base + "api/llm/v1",
|
|
193
|
+
OPENAI_API_KEY: session.token,
|
|
194
|
+
};
|
|
195
|
+
const tui = brandedTuiDir();
|
|
196
|
+
if (tui) extra.HERMES_TUI_DIR = tui;
|
|
197
|
+
return hermesEnv(extra);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function installProfileAssets(io) {
|
|
201
|
+
const err = (io && io.stderr) || process.stderr;
|
|
202
|
+
const src = distSource();
|
|
203
|
+
if (!src) {
|
|
204
|
+
err.write("DeepSQL agent profile assets not found in this install.\n");
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
const bin = hermesBin();
|
|
208
|
+
const baseEnv = hermesEnv();
|
|
209
|
+
const inst = spawnSync(
|
|
210
|
+
bin,
|
|
211
|
+
["profile", "install", src, "--name", PROFILE, "--force", "--yes"],
|
|
212
|
+
{ encoding: "utf8", env: baseEnv }
|
|
213
|
+
);
|
|
214
|
+
if (inst.status !== 0) {
|
|
215
|
+
err.write(
|
|
216
|
+
"DeepSQL Agent: provisioning the profile failed.\n" +
|
|
217
|
+
` ${(inst.stderr || inst.stdout || "(no output)").trim().split("\n").slice(-6).join("\n ")}\n`
|
|
218
|
+
);
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
// Skin loads from <profile>/skins/.
|
|
222
|
+
const skin = path.join(src, "skins", "deepsql.yaml");
|
|
223
|
+
if (fs.existsSync(skin)) {
|
|
224
|
+
const dst = path.join(PROFILE_HOME, "skins");
|
|
225
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
226
|
+
fs.copyFileSync(skin, path.join(dst, "deepsql.yaml"));
|
|
227
|
+
}
|
|
228
|
+
// Read-only sandbox: only deepsql + skills/memory remain. Non-fatal — the
|
|
229
|
+
// shipped distribution already scopes tools; this is defense in depth.
|
|
230
|
+
spawnSync(
|
|
231
|
+
bin,
|
|
232
|
+
["tools", "disable", "terminal", "file", "code_execution", "browser", "computer_use",
|
|
233
|
+
"image_gen", "tts", "vision", "web", "delegation", "cronjob"],
|
|
234
|
+
{ stdio: "ignore", env: hermesEnv({ HERMES_HOME: PROFILE_HOME }) }
|
|
235
|
+
);
|
|
236
|
+
return fs.existsSync(PROFILE_HOME);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ensureProfile(session, io) {
|
|
240
|
+
if (!fs.existsSync(path.join(PROFILE_HOME, "config.yaml"))) {
|
|
241
|
+
if (!installProfileAssets(io)) return false;
|
|
242
|
+
}
|
|
243
|
+
writeRuntimeConfig(session); // always refresh the token/connection for this launch
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Installed Hermes version (parsed from the runtime's __init__.py). Null if the
|
|
248
|
+
// runtime layout isn't what we expect (e.g. PATH-only install).
|
|
249
|
+
function installedHermesVersion() {
|
|
250
|
+
try {
|
|
251
|
+
const init = path.join(AGENT_DIR, "hermes_cli", "__init__.py");
|
|
252
|
+
const m = fs.readFileSync(init, "utf8").match(/__version__\s*=\s*["']([^"']+)["']/);
|
|
253
|
+
return m ? m[1] : null;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// The DeepSQL-branded prebuilt TUI bundle shipped in the package, as a directory
|
|
260
|
+
// laid out the way Hermes' `HERMES_TUI_DIR` mechanism expects: `<dir>/dist/entry.js`.
|
|
261
|
+
// When that env var points here, Hermes runs our entry.js DIRECTLY — no npm
|
|
262
|
+
// install, no esbuild — so the custom DEEPSQL wordmark / welcome panel / DBA
|
|
263
|
+
// placeholders show on the very FIRST launch. (The earlier copy-into-the-clone
|
|
264
|
+
// approach lost every launch because Hermes "always esbuild"s the normal path.)
|
|
265
|
+
//
|
|
266
|
+
// Guarded by the Hermes version our bundle was built against: a prebuilt entry.js
|
|
267
|
+
// speaks one release's TUI↔CLI protocol, so we only point at it on an exact
|
|
268
|
+
// version match (the runtime is pinned to HERMES_COMMIT, so it matches on our
|
|
269
|
+
// installs). On any mismatch / missing bundle we return null and let Hermes use
|
|
270
|
+
// its own (stock) TUI, which still renders our skin (DeepSQL name + maroon).
|
|
271
|
+
function brandedTuiDir() {
|
|
272
|
+
try {
|
|
273
|
+
const tuiDir = path.resolve(__dirname, "..", "..", "agent-profile", "tui");
|
|
274
|
+
if (!fs.existsSync(path.join(tuiDir, "dist", "entry.js"))) return null;
|
|
275
|
+
const builtFor = fs.readFileSync(path.join(tuiDir, "HERMES_VERSION"), "utf8").trim();
|
|
276
|
+
const installed = installedHermesVersion();
|
|
277
|
+
if (!builtFor || !installed || builtFor !== installed) return null;
|
|
278
|
+
return tuiDir;
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
module.exports = {
|
|
285
|
+
ensureHermes,
|
|
286
|
+
ensureProfile,
|
|
287
|
+
brandedTuiDir,
|
|
288
|
+
hermesBin,
|
|
289
|
+
hermesInstalled,
|
|
290
|
+
launchEnv,
|
|
291
|
+
PROFILE,
|
|
292
|
+
PROFILE_HOME,
|
|
293
|
+
};
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const COMMANDS = {
|
|
17
|
+
agent: () => require("./commands/agent"),
|
|
17
18
|
login: () => require("./commands/login"),
|
|
18
19
|
logout: () => require("./commands/logout"),
|
|
19
20
|
whoami: () => require("./commands/whoami"),
|
|
@@ -46,6 +47,7 @@ const COMMANDS = {
|
|
|
46
47
|
// suffix in the listing and reveal their full usage via `<command> --help`.
|
|
47
48
|
|
|
48
49
|
const COMMAND_LIST = [
|
|
50
|
+
["agent", false, "Launch the DeepSQL Agent — interactive chat TUI (also: bare `deepsql`)"],
|
|
49
51
|
["login", false, "Authorize this CLI with a DeepSQL instance"],
|
|
50
52
|
["logout", false, "Revoke and forget the saved token"],
|
|
51
53
|
["whoami", false, "Show the user behind the saved token"],
|
|
@@ -87,6 +89,16 @@ const GLOBAL_OPTIONS = [
|
|
|
87
89
|
// constant; the dispatcher renders them on `<command> --help`.
|
|
88
90
|
|
|
89
91
|
const COMMAND_HELP = {
|
|
92
|
+
agent: {
|
|
93
|
+
description: "Launch the DeepSQL Agent — an interactive chat TUI for DBA work, BI questions, and database guardrails. Running `deepsql` with no command in a terminal launches it too.",
|
|
94
|
+
usage: "deepsql agent [options] (or just: deepsql)",
|
|
95
|
+
options: [
|
|
96
|
+
["--url <url>", "DeepSQL instance to use (default: saved login)"],
|
|
97
|
+
["--connection <name>", "Default connection for the session"],
|
|
98
|
+
],
|
|
99
|
+
notes: "First run installs the agent runtime. Uses your saved `deepsql login` — no LLM key needed (the backend proxies the model).",
|
|
100
|
+
},
|
|
101
|
+
|
|
90
102
|
login: {
|
|
91
103
|
description: "Authorize this CLI with a DeepSQL instance.",
|
|
92
104
|
usage: "deepsql login [options]",
|
|
@@ -650,7 +662,7 @@ async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
|
650
662
|
const stdout = io.stdout || process.stdout;
|
|
651
663
|
const useColor = colorEnabled(stdout, rawArgv);
|
|
652
664
|
|
|
653
|
-
if (
|
|
665
|
+
if (isHelpFlag(rawArgv[0])) {
|
|
654
666
|
stdout.write(`${renderRootHelp(useColor)}\n`);
|
|
655
667
|
return 0;
|
|
656
668
|
}
|
|
@@ -659,8 +671,14 @@ async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
|
659
671
|
stdout.write(`${pkg.version}\n`);
|
|
660
672
|
return 0;
|
|
661
673
|
}
|
|
674
|
+
// No subcommand: launch the DeepSQL Agent TUI in an interactive terminal;
|
|
675
|
+
// fall back to the root help when stdout is piped/redirected (scripts, CI).
|
|
676
|
+
if (rawArgv.length === 0 && !stdout.isTTY) {
|
|
677
|
+
stdout.write(`${renderRootHelp(useColor)}\n`);
|
|
678
|
+
return 0;
|
|
679
|
+
}
|
|
662
680
|
|
|
663
|
-
const command = rawArgv[0];
|
|
681
|
+
const command = rawArgv.length === 0 ? "agent" : rawArgv[0];
|
|
664
682
|
const loader = COMMANDS[command];
|
|
665
683
|
if (!loader) {
|
|
666
684
|
stderr.write(`Unknown command: ${command}\n\n${renderRootHelp(colorEnabled(stderr, rawArgv))}\n`);
|
|
@@ -668,7 +686,7 @@ async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
|
668
686
|
}
|
|
669
687
|
|
|
670
688
|
// Per-command help: `deepsql <command> --help` / `-h` (anywhere in args).
|
|
671
|
-
const restArgs = rawArgv.slice(1);
|
|
689
|
+
const restArgs = rawArgv.length === 0 ? [] : rawArgv.slice(1);
|
|
672
690
|
if (restArgs.some(isHelpFlag)) {
|
|
673
691
|
stdout.write(`${renderCommandHelp(command, useColor)}\n`);
|
|
674
692
|
return 0;
|
|
@@ -690,9 +708,17 @@ async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
|
690
708
|
version: pkg.version,
|
|
691
709
|
});
|
|
692
710
|
|
|
711
|
+
const previousExitCode = process.exitCode;
|
|
712
|
+
process.exitCode = undefined;
|
|
693
713
|
try {
|
|
694
714
|
const mod = loader();
|
|
695
|
-
await mod.run(opts, { stdout, stderr });
|
|
715
|
+
const commandCode = await mod.run(opts, { stdout, stderr });
|
|
716
|
+
if (typeof commandCode === "number") {
|
|
717
|
+
return commandCode;
|
|
718
|
+
}
|
|
719
|
+
if (typeof process.exitCode === "number" && process.exitCode !== 0) {
|
|
720
|
+
return process.exitCode;
|
|
721
|
+
}
|
|
696
722
|
return 0;
|
|
697
723
|
} catch (err) {
|
|
698
724
|
stderr.write(`Error: ${err.message}\n`);
|
|
@@ -700,6 +726,12 @@ async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
|
700
726
|
stderr.write(`${err.stack}\n`);
|
|
701
727
|
}
|
|
702
728
|
return 1;
|
|
729
|
+
} finally {
|
|
730
|
+
if (previousExitCode == null) {
|
|
731
|
+
process.exitCode = undefined;
|
|
732
|
+
} else {
|
|
733
|
+
process.exitCode = previousExitCode;
|
|
734
|
+
}
|
|
703
735
|
}
|
|
704
736
|
}
|
|
705
737
|
|
package/src/cli.test.js
CHANGED
|
@@ -73,6 +73,7 @@ test("every command in the catalog has a COMMAND_HELP entry", () => {
|
|
|
73
73
|
const { main: _main } = require("./cli");
|
|
74
74
|
void _main;
|
|
75
75
|
const expected = [
|
|
76
|
+
"agent",
|
|
76
77
|
"login","logout","whoami","config","mcp","connections","query","analyze","schema",
|
|
77
78
|
"digest","brain-context","business-rules","relationships","anti-patterns","indexes",
|
|
78
79
|
"users","access","permissions","slow-queries","setup",
|
|
@@ -108,6 +109,13 @@ test("main rejects unknown commands and shows the root help", async () => {
|
|
|
108
109
|
assert.match(io.err(), /Usage: deepsql/);
|
|
109
110
|
});
|
|
110
111
|
|
|
112
|
+
test("main preserves a non-zero command return code", async () => {
|
|
113
|
+
const io = captureStreams();
|
|
114
|
+
const code = await main(["connections", "current", "--url", "http://x", "--token", "t"], io);
|
|
115
|
+
assert.equal(code, 1);
|
|
116
|
+
assert.match(io.err(), /No active connection set/);
|
|
117
|
+
});
|
|
118
|
+
|
|
111
119
|
test("--no-color suppresses ANSI escapes in help output", async () => {
|
|
112
120
|
const io = captureStreams();
|
|
113
121
|
// Force a TTY so the only thing turning color off is --no-color itself.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// `deepsql agent` (and bare `deepsql` in a terminal): launch the DeepSQL Agent —
|
|
4
|
+
// a branded Hermes TUI for DBA / BI / database-guard chat. Resolves the saved
|
|
5
|
+
// login, lazily installs the runtime, provisions the `deepsql` profile pointed
|
|
6
|
+
// at the backend LLM proxy (no LLM key needed), then hands the terminal to the TUI.
|
|
7
|
+
|
|
8
|
+
const { spawn } = require("node:child_process");
|
|
9
|
+
const { resolveSession } = require("./_session");
|
|
10
|
+
const { ensureHermes, ensureProfile, hermesBin, launchEnv, PROFILE } = require("../agent/bootstrap");
|
|
11
|
+
|
|
12
|
+
async function run(opts, io = {}) {
|
|
13
|
+
const stderr = io.stderr || process.stderr;
|
|
14
|
+
|
|
15
|
+
let session;
|
|
16
|
+
try {
|
|
17
|
+
session = resolveSession(opts);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
stderr.write(`${e.message}\n`);
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!ensureHermes(io)) {
|
|
24
|
+
stderr.write("Could not set up the DeepSQL Agent runtime. See https://github.com/NousResearch/hermes-agent for manual install.\n");
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
if (!ensureProfile(session, io)) {
|
|
28
|
+
stderr.write("Could not provision the DeepSQL Agent profile.\n");
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
// Branding is delivered via HERMES_TUI_DIR in launchEnv (Hermes runs our
|
|
32
|
+
// prebuilt branded TUI directly — no esbuild), set just below.
|
|
33
|
+
|
|
34
|
+
// Hand the terminal to the branded TUI (profile config sets interface=tui).
|
|
35
|
+
// launchEnv augments PATH (node/uv for the TUI + MCP server) and injects the
|
|
36
|
+
// provider vars that keep Hermes' first-run guard from ever firing.
|
|
37
|
+
const child = spawn(hermesBin(), ["-p", PROFILE, "--tui"], { stdio: "inherit", env: launchEnv(session) });
|
|
38
|
+
return await new Promise((resolve) => {
|
|
39
|
+
child.on("exit", (code) => resolve(code ?? 0));
|
|
40
|
+
child.on("error", (err) => {
|
|
41
|
+
stderr.write(`Failed to launch the DeepSQL Agent: ${err.message}\n`);
|
|
42
|
+
resolve(1);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { run };
|
|
@@ -333,10 +333,11 @@ async function runTest(opts, { stdout = process.stdout, stderr = process.stderr
|
|
|
333
333
|
throw new Error("Usage: deepsql connections test <name> | --from-file <path>");
|
|
334
334
|
}
|
|
335
335
|
const connectionId = await resolveConnectionId(session, target);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
//
|
|
339
|
-
|
|
336
|
+
// Send only the id for saved connections. The list/show APIs intentionally
|
|
337
|
+
// omit secrets, so posting that masked summary back to /connections/test
|
|
338
|
+
// breaks SSH/SSL/password-backed connections. The backend hydrates the
|
|
339
|
+
// saved decrypted config when an id is present.
|
|
340
|
+
cfg = { id: connectionId };
|
|
340
341
|
}
|
|
341
342
|
|
|
342
343
|
const result = await request(session.baseUrl, "/connections/test", {
|
|
@@ -352,7 +353,9 @@ async function runTest(opts, { stdout = process.stdout, stderr = process.stderr
|
|
|
352
353
|
printPrivilegeReport(stdout, result);
|
|
353
354
|
if (!result?.connectionSuccessful) {
|
|
354
355
|
process.exitCode = 1;
|
|
356
|
+
return 1;
|
|
355
357
|
}
|
|
358
|
+
return 0;
|
|
356
359
|
}
|
|
357
360
|
|
|
358
361
|
// ─── show ──────────────────────────────────────────────────────────────────
|