mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(cli): bundle official DBX agent skill
This commit is contained in:
@@ -151,6 +151,7 @@ export function inferAreaLabels(changedFiles) {
|
||||
if (has((file) => file.startsWith("crates/dbx-cli/")
|
||||
|| file.startsWith("packages/cli-")
|
||||
|| file.startsWith("packages/cli/")
|
||||
|| file.startsWith("skills/dbx/")
|
||||
|| file.startsWith("examples/cli/"))) {
|
||||
labels.add("area/cli");
|
||||
}
|
||||
|
||||
@@ -53,6 +53,16 @@ test("labels documentation-only changes without a conventional title", () => {
|
||||
assert.deepEqual(result.labels, ["area/docs", "documentation"]);
|
||||
});
|
||||
|
||||
test("labels the bundled DBX skill as CLI work", () => {
|
||||
const result = evaluatePullRequestLabels({
|
||||
title: "feat(cli): improve the bundled agent skill",
|
||||
changedFiles: ["skills/dbx/SKILL.md", "skills/dbx/references/commands.md"],
|
||||
knownDatabaseTypes,
|
||||
});
|
||||
|
||||
assert.deepEqual(result.labels, ["area/cli", "enhancement"]);
|
||||
});
|
||||
|
||||
test("maps agent and dialect paths to existing database types", () => {
|
||||
assert.deepEqual(
|
||||
inferDatabaseTypes([
|
||||
|
||||
@@ -973,6 +973,7 @@ jobs:
|
||||
- 'packages/mcp-server/**'
|
||||
- 'crates/dbx-cli/**'
|
||||
- 'crates/dbx-mcp/**'
|
||||
- 'skills/dbx/**'
|
||||
- 'scripts/verify-package-install.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
Generated
+2
@@ -2133,9 +2133,11 @@ dependencies = [
|
||||
"async-trait",
|
||||
"dbx-core",
|
||||
"dbx-mcp",
|
||||
"dirs",
|
||||
"rustls 0.23.43",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -335,10 +335,13 @@ DBX also provides a dedicated CLI package for terminal, script, and Codex workfl
|
||||
npm install -g @dbx-app/cli
|
||||
# or via Homebrew
|
||||
brew tap t8y2/tap && brew install dbx-cli
|
||||
dbx agent setup
|
||||
dbx connections list --json
|
||||
dbx query local "select 1" --json
|
||||
```
|
||||
|
||||
The CLI includes the official DBX Agent Skill. `dbx agent setup` installs or updates it offline under `~/.agents/skills/dbx`, so shell-capable AI agents know how to use DBX CLI safely.
|
||||
|
||||
See the [MCP server README](packages/mcp-server/README.md) and [CLI README](packages/cli/README.md) for details.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -333,10 +333,13 @@ DBX 也提供独立 CLI 包,适合终端、脚本和 Codex 工作流:
|
||||
npm install -g @dbx-app/cli
|
||||
# 或通过 Homebrew
|
||||
brew tap t8y2/tap && brew install dbx-cli
|
||||
dbx agent setup
|
||||
dbx connections list --json
|
||||
dbx query local "select 1" --json
|
||||
```
|
||||
|
||||
CLI 已内置官方 DBX Agent Skill;`dbx agent setup` 会离线安装或更新到 `~/.agents/skills/dbx`,让支持 Shell 的 AI Agent 知道如何安全调用 DBX CLI。
|
||||
|
||||
详见 [MCP Server 说明](packages/mcp-server/README.md) 和 [CLI 说明](packages/cli/README.md)。
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -11,11 +11,13 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
dbx-core = { path = "../dbx-core", default-features = false, features = ["sqlite-bundled"] }
|
||||
dbx-mcp = { path = "../dbx-mcp" }
|
||||
dirs = "6"
|
||||
rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::Write,
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{json_string, CliError, OutputFormat};
|
||||
|
||||
const SKILL_NAME: &str = "dbx";
|
||||
const SKILL_VERSION: &str = "1.1.0";
|
||||
const STATE_FILE_NAME: &str = ".dbx-managed.json";
|
||||
const STATE_SCHEMA_VERSION: u8 = 1;
|
||||
|
||||
struct EmbeddedFile {
|
||||
relative_path: &'static str,
|
||||
contents: &'static str,
|
||||
}
|
||||
|
||||
const EMBEDDED_FILES: &[EmbeddedFile] = &[
|
||||
EmbeddedFile { relative_path: "SKILL.md", contents: include_str!("../../../skills/dbx/SKILL.md") },
|
||||
EmbeddedFile {
|
||||
relative_path: "references/commands.md",
|
||||
contents: include_str!("../../../skills/dbx/references/commands.md"),
|
||||
},
|
||||
EmbeddedFile {
|
||||
relative_path: "references/safety.md",
|
||||
contents: include_str!("../../../skills/dbx/references/safety.md"),
|
||||
},
|
||||
EmbeddedFile {
|
||||
relative_path: "references/workflows.md",
|
||||
contents: include_str!("../../../skills/dbx/references/workflows.md"),
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum InstallationStatus {
|
||||
NotInstalled,
|
||||
UpToDate,
|
||||
UpdateAvailable,
|
||||
Unmanaged,
|
||||
Modified,
|
||||
}
|
||||
|
||||
impl InstallationStatus {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NotInstalled => "not_installed",
|
||||
Self::UpToDate => "up_to_date",
|
||||
Self::UpdateAvailable => "update_available",
|
||||
Self::Unmanaged => "unmanaged",
|
||||
Self::Modified => "modified",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inspection {
|
||||
status: InstallationStatus,
|
||||
installed_version: Option<String>,
|
||||
managed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ManagedState {
|
||||
schema_version: u8,
|
||||
skill_name: String,
|
||||
skill_version: String,
|
||||
files: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentSkillReport {
|
||||
action: String,
|
||||
status: String,
|
||||
skill_name: String,
|
||||
bundled_version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
installed_version: Option<String>,
|
||||
managed: bool,
|
||||
path: String,
|
||||
}
|
||||
|
||||
pub fn run(args: &[String], format: OutputFormat, skills_dir: Option<&Path>, force: bool) -> Result<String, CliError> {
|
||||
if format == OutputFormat::Csv {
|
||||
return Err(CliError::new("INVALID_OPTION", "CSV format is not supported for dbx agent commands."));
|
||||
}
|
||||
if args.len() != 2 {
|
||||
return Err(CliError::new("INVALID_ARGUMENT", "Usage: dbx agent <setup|status> [--json]"));
|
||||
}
|
||||
|
||||
let root = resolve_skills_dir(skills_dir)?;
|
||||
let target = root.join(SKILL_NAME);
|
||||
match args[1].as_str() {
|
||||
"setup" => setup(&target, force, format),
|
||||
"status" => {
|
||||
if force {
|
||||
return Err(CliError::new("INVALID_OPTION", "--force is only supported by dbx agent setup."));
|
||||
}
|
||||
let inspection = inspect(&target)?;
|
||||
render_report(report("status", &target, inspection), format)
|
||||
}
|
||||
command => Err(CliError::new(
|
||||
"INVALID_ARGUMENT",
|
||||
format!("Unknown agent command: {command}. Use dbx agent setup or dbx agent status."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(target: &Path, force: bool, format: OutputFormat) -> Result<String, CliError> {
|
||||
reject_symlink(target)?;
|
||||
let inspection = inspect(target)?;
|
||||
let action = match inspection.status {
|
||||
InstallationStatus::NotInstalled => "installed",
|
||||
InstallationStatus::UpToDate => {
|
||||
write_state(target)?;
|
||||
"unchanged"
|
||||
}
|
||||
InstallationStatus::UpdateAvailable => "updated",
|
||||
InstallationStatus::Unmanaged | InstallationStatus::Modified if !force => {
|
||||
return Err(CliError::new(
|
||||
"SKILL_MODIFIED",
|
||||
format!(
|
||||
"The DBX Agent Skill at {} is unmanaged or has local changes. Re-run with --force to replace DBX-managed files.",
|
||||
target.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
InstallationStatus::Unmanaged | InstallationStatus::Modified => "replaced",
|
||||
};
|
||||
|
||||
if action != "unchanged" {
|
||||
write_embedded_files(target)?;
|
||||
write_state(target)?;
|
||||
}
|
||||
let inspection = inspect(target)?;
|
||||
render_report(report(action, target, inspection), format)
|
||||
}
|
||||
|
||||
fn resolve_skills_dir(override_dir: Option<&Path>) -> Result<PathBuf, CliError> {
|
||||
if let Some(path) = override_dir {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|home| home.join(".agents").join("skills"))
|
||||
.ok_or_else(|| CliError::new("HOME_NOT_FOUND", "Unable to determine the user home directory."))
|
||||
}
|
||||
|
||||
fn inspect(target: &Path) -> Result<Inspection, CliError> {
|
||||
reject_symlink(target)?;
|
||||
let skill_path = target.join("SKILL.md");
|
||||
let state = read_state(target);
|
||||
if !skill_path.exists() {
|
||||
let has_other_embedded_files = EMBEDDED_FILES
|
||||
.iter()
|
||||
.filter(|embedded| embedded.relative_path != "SKILL.md")
|
||||
.any(|embedded| target.join(embedded.relative_path).exists());
|
||||
return Ok(match state {
|
||||
Some(state) => Inspection {
|
||||
status: InstallationStatus::Modified,
|
||||
installed_version: Some(state.skill_version),
|
||||
managed: true,
|
||||
},
|
||||
None if has_other_embedded_files => {
|
||||
Inspection { status: InstallationStatus::Unmanaged, installed_version: None, managed: false }
|
||||
}
|
||||
None => Inspection { status: InstallationStatus::NotInstalled, installed_version: None, managed: false },
|
||||
});
|
||||
}
|
||||
reject_symlink(&skill_path)?;
|
||||
|
||||
let skill_contents = read_file(&skill_path)?;
|
||||
let installed_version =
|
||||
state.as_ref().map(|state| state.skill_version.clone()).or_else(|| frontmatter_version(&skill_contents));
|
||||
|
||||
if embedded_files_match(target)? {
|
||||
return Ok(Inspection {
|
||||
status: InstallationStatus::UpToDate,
|
||||
installed_version: Some(SKILL_VERSION.to_string()),
|
||||
managed: state.is_some(),
|
||||
});
|
||||
}
|
||||
|
||||
match state {
|
||||
Some(state) if managed_files_unchanged(target, &state)? => {
|
||||
Ok(Inspection { status: InstallationStatus::UpdateAvailable, installed_version, managed: true })
|
||||
}
|
||||
Some(_) => Ok(Inspection { status: InstallationStatus::Modified, installed_version, managed: true }),
|
||||
None => Ok(Inspection { status: InstallationStatus::Unmanaged, installed_version, managed: false }),
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded_files_match(target: &Path) -> Result<bool, CliError> {
|
||||
for embedded in EMBEDDED_FILES {
|
||||
let path = safe_join(target, embedded.relative_path)?;
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
reject_symlink(&path)?;
|
||||
if read_file(&path)? != embedded.contents {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn managed_files_unchanged(target: &Path, state: &ManagedState) -> Result<bool, CliError> {
|
||||
if state.schema_version != STATE_SCHEMA_VERSION || state.skill_name != SKILL_NAME {
|
||||
return Ok(false);
|
||||
}
|
||||
for (relative_path, expected_hash) in &state.files {
|
||||
let path = safe_join(target, relative_path)?;
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
reject_symlink(&path)?;
|
||||
if content_hash(read_file(&path)?.as_bytes()) != *expected_hash {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
for embedded in EMBEDDED_FILES {
|
||||
if state.files.contains_key(embedded.relative_path) {
|
||||
continue;
|
||||
}
|
||||
let path = safe_join(target, embedded.relative_path)?;
|
||||
if path.exists() {
|
||||
reject_symlink(&path)?;
|
||||
if read_file(&path)? != embedded.contents {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn write_embedded_files(target: &Path) -> Result<(), CliError> {
|
||||
fs::create_dir_all(target).map_err(|error| io_error("SKILL_WRITE_FAILED", target, error))?;
|
||||
reject_symlink(target)?;
|
||||
for embedded in EMBEDDED_FILES {
|
||||
let path = safe_join(target, embedded.relative_path)?;
|
||||
reject_symlink(&path)?;
|
||||
write_atomic(&path, embedded.contents.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_state(target: &Path) -> Result<(), CliError> {
|
||||
fs::create_dir_all(target).map_err(|error| io_error("SKILL_WRITE_FAILED", target, error))?;
|
||||
let state = bundled_state();
|
||||
let contents = serde_json::to_vec_pretty(&state).map_err(|error| CliError::new("ERROR", error.to_string()))?;
|
||||
write_atomic(&target.join(STATE_FILE_NAME), &contents)
|
||||
}
|
||||
|
||||
fn bundled_state() -> ManagedState {
|
||||
let files = EMBEDDED_FILES
|
||||
.iter()
|
||||
.map(|embedded| (embedded.relative_path.to_string(), content_hash(embedded.contents.as_bytes())))
|
||||
.collect();
|
||||
ManagedState {
|
||||
schema_version: STATE_SCHEMA_VERSION,
|
||||
skill_name: SKILL_NAME.to_string(),
|
||||
skill_version: SKILL_VERSION.to_string(),
|
||||
files,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_state(target: &Path) -> Option<ManagedState> {
|
||||
let path = target.join(STATE_FILE_NAME);
|
||||
reject_symlink(&path).ok()?;
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
fn read_file(path: &Path) -> Result<String, CliError> {
|
||||
fs::read_to_string(path).map_err(|error| io_error("SKILL_READ_FAILED", path, error))
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, contents: &[u8]) -> Result<(), CliError> {
|
||||
reject_symlink(path)?;
|
||||
let parent = path.parent().ok_or_else(|| CliError::new("SKILL_WRITE_FAILED", "Skill path has no parent."))?;
|
||||
fs::create_dir_all(parent).map_err(|error| io_error("SKILL_WRITE_FAILED", parent, error))?;
|
||||
let mut temporary =
|
||||
tempfile::NamedTempFile::new_in(parent).map_err(|error| io_error("SKILL_WRITE_FAILED", parent, error))?;
|
||||
temporary
|
||||
.write_all(contents)
|
||||
.and_then(|_| temporary.as_file().sync_all())
|
||||
.map_err(|error| io_error("SKILL_WRITE_FAILED", path, error))?;
|
||||
temporary.persist(path).map_err(|error| io_error("SKILL_WRITE_FAILED", path, error.error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_symlink(path: &Path) -> Result<(), CliError> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => Err(CliError::new(
|
||||
"SKILL_PATH_UNSAFE",
|
||||
format!("Refusing to write through symbolic link {}.", path.display()),
|
||||
)),
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_error("SKILL_READ_FAILED", path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_join(root: &Path, relative_path: &str) -> Result<PathBuf, CliError> {
|
||||
let relative = Path::new(relative_path);
|
||||
if relative.is_absolute() || relative.components().any(|component| !matches!(component, Component::Normal(_))) {
|
||||
return Err(CliError::new("SKILL_PATH_UNSAFE", "Managed skill metadata contains an unsafe path."));
|
||||
}
|
||||
Ok(root.join(relative))
|
||||
}
|
||||
|
||||
fn content_hash(contents: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(contents))
|
||||
}
|
||||
|
||||
fn frontmatter_version(contents: &str) -> Option<String> {
|
||||
let mut lines = contents.lines();
|
||||
if lines.next()? != "---" {
|
||||
return None;
|
||||
}
|
||||
for line in lines {
|
||||
if line == "---" {
|
||||
break;
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("version:") {
|
||||
return Some(value.trim().trim_matches(['\'', '"']).to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn report(action: &str, target: &Path, inspection: Inspection) -> AgentSkillReport {
|
||||
AgentSkillReport {
|
||||
action: action.to_string(),
|
||||
status: inspection.status.as_str().to_string(),
|
||||
skill_name: SKILL_NAME.to_string(),
|
||||
bundled_version: SKILL_VERSION.to_string(),
|
||||
installed_version: inspection.installed_version,
|
||||
managed: inspection.managed,
|
||||
path: target.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_report(report: AgentSkillReport, format: OutputFormat) -> Result<String, CliError> {
|
||||
if format == OutputFormat::Json {
|
||||
return json_string(&report);
|
||||
}
|
||||
Ok(format!(
|
||||
"DBX Agent Skill\nAction: {}\nStatus: {}\nBundled version: {}\nInstalled version: {}\nManaged: {}\nPath: {}\n",
|
||||
report.action,
|
||||
report.status,
|
||||
report.bundled_version,
|
||||
report.installed_version.as_deref().unwrap_or("not installed"),
|
||||
if report.managed { "yes" } else { "no" },
|
||||
report.path
|
||||
))
|
||||
}
|
||||
|
||||
fn io_error(code: &'static str, path: &Path, error: std::io::Error) -> CliError {
|
||||
CliError::new(code, format!("{}: {error}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_skill_version_matches_frontmatter() {
|
||||
let main = EMBEDDED_FILES.iter().find(|file| file.relative_path == "SKILL.md").unwrap();
|
||||
assert_eq!(frontmatter_version(main.contents).as_deref(), Some(SKILL_VERSION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_command_reference_covers_the_cli_surface() {
|
||||
let commands =
|
||||
EMBEDDED_FILES.iter().find(|file| file.relative_path == "references/commands.md").unwrap().contents;
|
||||
for command in [
|
||||
"dbx doctor",
|
||||
"dbx capabilities",
|
||||
"dbx agent setup",
|
||||
"dbx agent status",
|
||||
"dbx connections list",
|
||||
"dbx schema list",
|
||||
"dbx schema describe",
|
||||
"dbx query",
|
||||
"dbx context",
|
||||
"dbx dbml",
|
||||
"dbx docs",
|
||||
"dbx open",
|
||||
] {
|
||||
assert!(commands.contains(command), "command reference is missing {command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_installs_and_reports_the_bundled_skill() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let output = run(&args(&["agent", "setup"]), OutputFormat::Json, Some(directory.path()), false).unwrap();
|
||||
let output: serde_json::Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(output["action"], "installed");
|
||||
assert_eq!(output["status"], "up_to_date");
|
||||
assert_eq!(output["bundledVersion"], SKILL_VERSION);
|
||||
assert!(directory.path().join("dbx/SKILL.md").exists());
|
||||
assert!(directory.path().join("dbx/references/commands.md").exists());
|
||||
assert!(directory.path().join(format!("dbx/{STATE_FILE_NAME}")).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_setup_is_idempotent() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
run(&args(&["agent", "setup"]), OutputFormat::Table, Some(directory.path()), false).unwrap();
|
||||
let output = run(&args(&["agent", "setup"]), OutputFormat::Table, Some(directory.path()), false).unwrap();
|
||||
assert!(output.contains("Action: unchanged"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_preserves_an_unmanaged_skill_without_force() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let target = directory.path().join("dbx");
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
fs::write(target.join("SKILL.md"), "custom skill").unwrap();
|
||||
let error = run(&args(&["agent", "setup"]), OutputFormat::Table, Some(directory.path()), false).unwrap_err();
|
||||
assert_eq!(error.code, "SKILL_MODIFIED");
|
||||
assert_eq!(fs::read_to_string(target.join("SKILL.md")).unwrap(), "custom skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_replaces_managed_paths_but_keeps_extra_files() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let target = directory.path().join("dbx");
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
fs::write(target.join("SKILL.md"), "custom skill").unwrap();
|
||||
fs::write(target.join("notes.md"), "keep me").unwrap();
|
||||
let output = run(&args(&["agent", "setup"]), OutputFormat::Table, Some(directory.path()), true).unwrap();
|
||||
assert!(output.contains("Action: replaced"));
|
||||
assert_eq!(fs::read_to_string(target.join("SKILL.md")).unwrap(), EMBEDDED_FILES[0].contents);
|
||||
assert_eq!(fs::read_to_string(target.join("notes.md")).unwrap(), "keep me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_reports_locally_modified_managed_skill() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
run(&args(&["agent", "setup"]), OutputFormat::Table, Some(directory.path()), false).unwrap();
|
||||
fs::write(directory.path().join("dbx/SKILL.md"), "edited").unwrap();
|
||||
let output = run(&args(&["agent", "status"]), OutputFormat::Json, Some(directory.path()), false).unwrap();
|
||||
let output: serde_json::Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(output["status"], "modified");
|
||||
assert_eq!(output["managed"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_reports_a_managed_update_without_overwriting_it() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let target = directory.path().join("dbx");
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
fs::write(target.join("SKILL.md"), "old managed skill").unwrap();
|
||||
let mut files = BTreeMap::new();
|
||||
files.insert("SKILL.md".to_string(), content_hash(b"old managed skill"));
|
||||
let old_state = ManagedState {
|
||||
schema_version: STATE_SCHEMA_VERSION,
|
||||
skill_name: SKILL_NAME.to_string(),
|
||||
skill_version: "1.0.0".to_string(),
|
||||
files,
|
||||
};
|
||||
fs::write(target.join(STATE_FILE_NAME), serde_json::to_vec(&old_state).unwrap()).unwrap();
|
||||
let output = run(&args(&["agent", "status"]), OutputFormat::Json, Some(directory.path()), false).unwrap();
|
||||
let output: serde_json::Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(output["status"], "update_available");
|
||||
assert_eq!(output["installedVersion"], "1.0.0");
|
||||
assert_eq!(fs::read_to_string(target.join("SKILL.md")).unwrap(), "old managed skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_updates_an_unchanged_managed_skill() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let target = directory.path().join("dbx");
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
fs::write(target.join("SKILL.md"), "old managed skill").unwrap();
|
||||
let mut files = BTreeMap::new();
|
||||
files.insert("SKILL.md".to_string(), content_hash(b"old managed skill"));
|
||||
let old_state = ManagedState {
|
||||
schema_version: STATE_SCHEMA_VERSION,
|
||||
skill_name: SKILL_NAME.to_string(),
|
||||
skill_version: "1.0.0".to_string(),
|
||||
files,
|
||||
};
|
||||
fs::write(target.join(STATE_FILE_NAME), serde_json::to_vec(&old_state).unwrap()).unwrap();
|
||||
let output = run(&args(&["agent", "setup"]), OutputFormat::Json, Some(directory.path()), false).unwrap();
|
||||
let output: serde_json::Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(output["action"], "updated");
|
||||
assert_eq!(output["status"], "up_to_date");
|
||||
assert_eq!(output["installedVersion"], SKILL_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_main_file_is_treated_as_a_local_modification() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
run(&args(&["agent", "setup"]), OutputFormat::Table, Some(directory.path()), false).unwrap();
|
||||
fs::remove_file(directory.path().join("dbx/SKILL.md")).unwrap();
|
||||
let error = run(&args(&["agent", "setup"]), OutputFormat::Table, Some(directory.path()), false).unwrap_err();
|
||||
assert_eq!(error.code, "SKILL_MODIFIED");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn setup_rejects_a_symlinked_skill_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
symlink(outside.path(), directory.path().join("dbx")).unwrap();
|
||||
let error = run(&args(&["agent", "setup"]), OutputFormat::Table, Some(directory.path()), false).unwrap_err();
|
||||
assert_eq!(error.code, "SKILL_PATH_UNSAFE");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
mod agent_skill;
|
||||
|
||||
use std::{env, path::PathBuf, process::ExitCode, sync::Arc};
|
||||
|
||||
use dbx_core::{
|
||||
@@ -100,8 +102,10 @@ struct Flags {
|
||||
out: Option<PathBuf>,
|
||||
notes: Option<PathBuf>,
|
||||
lang: Option<String>,
|
||||
skills_dir: Option<PathBuf>,
|
||||
allow_writes: bool,
|
||||
allow_dangerous: bool,
|
||||
force: bool,
|
||||
help: bool,
|
||||
version: bool,
|
||||
}
|
||||
@@ -191,6 +195,7 @@ async fn run(argv: Vec<String>) -> Result<String, (CliError, bool)> {
|
||||
if flags.args.is_empty() || flags.help || flags.args.first().is_some_and(|arg| arg == "help") {
|
||||
return Ok(format!("{}\n", usage()));
|
||||
}
|
||||
validate_agent_only_flags(&flags).map_err(|error| (error, json_output))?;
|
||||
if flags.args[0] == "doctor" {
|
||||
ensure_arg_count(&flags.args, 1, "dbx doctor").map_err(|error| (error, json_output))?;
|
||||
let diagnostics = diagnostics().await;
|
||||
@@ -200,6 +205,10 @@ async fn run(argv: Vec<String>) -> Result<String, (CliError, bool)> {
|
||||
ensure_arg_count(&flags.args, 1, "dbx capabilities").map_err(|error| (error, json_output))?;
|
||||
return format_capabilities(flags.format).map_err(|error| (error, json_output));
|
||||
}
|
||||
if flags.args[0] == "agent" {
|
||||
return agent_skill::run(&flags.args, flags.format, flags.skills_dir.as_deref(), flags.force)
|
||||
.map_err(|error| (error, json_output));
|
||||
}
|
||||
|
||||
let backend: Arc<dyn DbxBackend> = if let Ok(base_url) = env::var("DBX_WEB_URL") {
|
||||
Arc::new(
|
||||
@@ -593,8 +602,10 @@ fn parse_flags(argv: &[String]) -> Result<Flags, CliError> {
|
||||
out: None,
|
||||
notes: None,
|
||||
lang: None,
|
||||
skills_dir: None,
|
||||
allow_writes: false,
|
||||
allow_dangerous: false,
|
||||
force: false,
|
||||
help: false,
|
||||
version: false,
|
||||
};
|
||||
@@ -640,8 +651,10 @@ fn parse_flags(argv: &[String]) -> Result<Flags, CliError> {
|
||||
"--out" => flags.out = Some(PathBuf::from(option_value(argv, &mut index, "--out")?)),
|
||||
"--notes" => flags.notes = Some(PathBuf::from(option_value(argv, &mut index, "--notes")?)),
|
||||
"--lang" => flags.lang = Some(option_value(argv, &mut index, "--lang")?),
|
||||
"--skills-dir" => flags.skills_dir = Some(PathBuf::from(option_value(argv, &mut index, "--skills-dir")?)),
|
||||
"--allow-writes" => flags.allow_writes = true,
|
||||
"--allow-dangerous-sql" => flags.allow_dangerous = true,
|
||||
"--force" => flags.force = true,
|
||||
value if value.starts_with('-') => {
|
||||
return Err(CliError::new("UNKNOWN_OPTION", format!("Unknown option: {value}")))
|
||||
}
|
||||
@@ -688,6 +701,19 @@ fn duration_ms(value: &str, option: &'static str) -> Result<u64, CliError> {
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_agent_only_flags(flags: &Flags) -> Result<(), CliError> {
|
||||
if flags.args.first().is_some_and(|arg| arg == "agent") {
|
||||
return Ok(());
|
||||
}
|
||||
if flags.skills_dir.is_some() {
|
||||
return Err(CliError::new("INVALID_OPTION", "--skills-dir is only supported by dbx agent commands."));
|
||||
}
|
||||
if flags.force {
|
||||
return Err(CliError::new("INVALID_OPTION", "--force is only supported by dbx agent setup."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_arg_count(args: &[String], count: usize, command: &'static str) -> Result<(), CliError> {
|
||||
if args.len() == count {
|
||||
Ok(())
|
||||
@@ -1001,7 +1027,7 @@ fn csv_cell(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"Usage:\n dbx doctor [--json]\n dbx capabilities [--json]\n dbx connections list [--json]\n dbx schema list <connection> [--schema name] [--json]\n dbx schema describe <connection> <table> [--schema name] [--json]\n dbx query <connection> <sql> [--file path] [--limit n] [--timeout 10s] [--allow-writes] [--allow-dangerous-sql] [--json]\n dbx context <connection> [--schema name] [--tables a,b] [--max-tables n] [--json]\n dbx dbml <connection> [--out path] [--notes path] [--schema name] [--database name] [--tables a,b]\n dbx docs <connection> [--out path] [--notes path] [--lang code] [--schema name] [--database name] [--tables a,b]\n dbx open <connection> <table> [--schema name] [--database name] [--json]"
|
||||
"Usage:\n dbx doctor [--json]\n dbx capabilities [--json]\n dbx agent setup [--skills-dir path] [--force] [--json]\n dbx agent status [--skills-dir path] [--json]\n dbx connections list [--json]\n dbx schema list <connection> [--schema name] [--json]\n dbx schema describe <connection> <table> [--schema name] [--json]\n dbx query <connection> <sql> [--file path] [--limit n] [--timeout 10s] [--allow-writes] [--allow-dangerous-sql] [--json]\n dbx context <connection> [--schema name] [--tables a,b] [--max-tables n] [--json]\n dbx dbml <connection> [--out path] [--notes path] [--schema name] [--database name] [--tables a,b]\n dbx docs <connection> [--out path] [--notes path] [--lang code] [--schema name] [--database name] [--tables a,b]\n dbx open <connection> <table> [--schema name] [--database name] [--json]"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1137,6 +1163,31 @@ mod tests {
|
||||
assert!(flags.format == OutputFormat::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_agent_skill_setup_flags() {
|
||||
let flags =
|
||||
parse_flags(&args(&["agent", "setup", "--skills-dir", "/tmp/skills", "--force", "--json"])).unwrap();
|
||||
assert_eq!(flags.args, args(&["agent", "setup"]));
|
||||
assert_eq!(flags.skills_dir.as_deref(), Some(std::path::Path::new("/tmp/skills")));
|
||||
assert!(flags.force);
|
||||
assert_eq!(flags.format, OutputFormat::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_commands_appear_in_usage_text() {
|
||||
assert!(usage().contains("dbx agent setup"));
|
||||
assert!(usage().contains("dbx agent status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_agent_only_flags_for_other_commands() {
|
||||
let force = parse_flags(&args(&["query", "local", "select 1", "--force"])).unwrap();
|
||||
assert_eq!(validate_agent_only_flags(&force).unwrap_err().code, "INVALID_OPTION");
|
||||
|
||||
let skills_dir = parse_flags(&args(&["doctor", "--skills-dir", "/tmp/skills"])).unwrap();
|
||||
assert_eq!(validate_agent_only_flags(&skills_dir).unwrap_err().code, "INVALID_OPTION");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_double_dash_sql() {
|
||||
let flags = parse_flags(&args(&["query", "local", "--json", "--", "-- comment\nselect 1"])).unwrap();
|
||||
|
||||
@@ -40,11 +40,26 @@ chmod +x dbx
|
||||
dbx --version
|
||||
```
|
||||
|
||||
## 官方 Agent Skill
|
||||
|
||||
CLI 二进制内置了供 Codex、Claude Code 等可执行 Shell 的 AI Agent 使用的官方 DBX Skill。安装或升级 CLI 后启用一次:
|
||||
|
||||
```bash
|
||||
dbx agent setup
|
||||
dbx agent status
|
||||
```
|
||||
|
||||
默认安装到 `~/.agents/skills/dbx`。`setup` 全程离线运行,可重复执行并升级到 CLI 内置的新版本;如果目标位置存在非 DBX 管理或经过本地修改的 Skill,则不会覆盖,除非显式使用 `--force`。Agent 使用其它 Skill 根目录时可传入 `--skills-dir <路径>`。
|
||||
|
||||
Skill 负责告诉 AI 何时以及如何安全调用 CLI,并不会绕过 CLI、连接或数据库本身的权限限制。普通用户仍然可以直接运行所有 `dbx` 命令。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
dbx doctor
|
||||
dbx capabilities
|
||||
dbx agent setup
|
||||
dbx agent status --json
|
||||
dbx connections list --json
|
||||
dbx connections list --format csv
|
||||
dbx schema list local --json
|
||||
@@ -166,6 +181,11 @@ CLI JSON 错误使用稳定错误码:
|
||||
| `CONNECTION_NOT_FOUND` | 找不到指定的 DBX 连接 |
|
||||
| `SQL_BLOCKED` | SQL 被安全规则拦截 |
|
||||
| `DBX_NOT_RUNNING` | DBX 桌面端 bridge 不可用 |
|
||||
| `HOME_NOT_FOUND` | 无法确定默认用户 Skill 目录 |
|
||||
| `SKILL_MODIFIED` | Skill 未被 DBX 管理或已被修改 |
|
||||
| `SKILL_PATH_UNSAFE` | 受管理的 Skill 路径是符号链接 |
|
||||
| `SKILL_READ_FAILED` | 无法读取已安装的 Skill 文件 |
|
||||
| `SKILL_WRITE_FAILED` | 无法安装 CLI 内置的 Skill |
|
||||
| `ERROR` | 未预期的运行时错误 |
|
||||
|
||||
## 桌面端 Deep Link
|
||||
|
||||
@@ -40,11 +40,26 @@ Check the installed version:
|
||||
dbx --version
|
||||
```
|
||||
|
||||
## Official Agent Skill
|
||||
|
||||
The CLI binary includes the official DBX Skill for Codex, Claude Code, and other shell-capable AI agents. Enable it once after installing or upgrading the CLI:
|
||||
|
||||
```bash
|
||||
dbx agent setup
|
||||
dbx agent status
|
||||
```
|
||||
|
||||
The default destination is `~/.agents/skills/dbx`. `setup` runs entirely offline and can be repeated to upgrade to the version bundled with the CLI. It refuses to replace an unmanaged or locally modified Skill unless `--force` is supplied. Pass `--skills-dir <path>` when an agent reads a different skills root.
|
||||
|
||||
The Skill teaches the AI when and how to call the CLI safely. It does not bypass CLI, connection, or database permissions, and people can continue to run every `dbx` command directly.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
dbx doctor
|
||||
dbx capabilities
|
||||
dbx agent setup
|
||||
dbx agent status --json
|
||||
dbx connections list --json
|
||||
dbx connections list --format csv
|
||||
dbx schema list local --json
|
||||
@@ -166,6 +181,11 @@ CLI JSON errors use stable codes:
|
||||
| `CONNECTION_NOT_FOUND` | No DBX connection matched the requested name |
|
||||
| `SQL_BLOCKED` | SQL safety rules blocked execution |
|
||||
| `DBX_NOT_RUNNING` | DBX Desktop bridge is unavailable |
|
||||
| `HOME_NOT_FOUND` | The default user skills directory cannot be resolved |
|
||||
| `SKILL_MODIFIED` | An unmanaged or locally edited DBX Skill was found |
|
||||
| `SKILL_PATH_UNSAFE` | A managed Skill path is a symbolic link |
|
||||
| `SKILL_READ_FAILED` | An installed Skill file could not be read |
|
||||
| `SKILL_WRITE_FAILED` | The bundled Skill could not be installed |
|
||||
| `ERROR` | Unexpected runtime failure |
|
||||
|
||||
## Desktop Deep Links
|
||||
|
||||
@@ -43,11 +43,24 @@ chmod +x dbx
|
||||
|
||||
Standalone binaries do not require Node.js. They read the same DBX connection storage as the desktop application; set `DBX_DATA_DIR` when using a custom or portable data directory.
|
||||
|
||||
## Official Agent Skill
|
||||
|
||||
The CLI binary includes the official DBX Skill for shell-capable AI agents. Enable it once after installing or upgrading the CLI:
|
||||
|
||||
```bash
|
||||
dbx agent setup
|
||||
dbx agent status
|
||||
```
|
||||
|
||||
The default location is `~/.agents/skills/dbx`. `setup` runs entirely offline and can be repeated to install a newer bundled version. It refuses to overwrite an unmanaged or locally modified DBX Skill unless `--force` is supplied. Use `--skills-dir <path>` when the agent reads a different skills root.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
dbx doctor
|
||||
dbx capabilities
|
||||
dbx agent setup
|
||||
dbx agent status --json
|
||||
dbx connections list --json
|
||||
dbx connections list --format csv
|
||||
dbx schema list local --json
|
||||
@@ -66,6 +79,8 @@ dbx open local users
|
||||
| ------------------------------------------- | ----------------------------------------------------- |
|
||||
| `dbx doctor` | Show local DBX config and desktop bridge diagnostics |
|
||||
| `dbx capabilities` | Show direct-query and desktop-bridge database support |
|
||||
| `dbx agent setup` | Install or update the bundled official DBX Skill |
|
||||
| `dbx agent status` | Inspect the installed DBX Skill |
|
||||
| `dbx connections list` | List DBX connections without printing secrets |
|
||||
| `dbx schema list <connection>` | List tables and views |
|
||||
| `dbx schema describe <connection> <table>` | Show table columns |
|
||||
@@ -140,6 +155,11 @@ CLI JSON errors use stable codes:
|
||||
| `CONNECTION_NOT_FOUND` | No DBX connection matched the requested name |
|
||||
| `SQL_BLOCKED` | SQL safety rules blocked execution |
|
||||
| `DBX_NOT_RUNNING` | DBX Desktop bridge is unavailable |
|
||||
| `HOME_NOT_FOUND` | The default user skills directory cannot be resolved |
|
||||
| `SKILL_MODIFIED` | An unmanaged or locally edited DBX Skill was found |
|
||||
| `SKILL_PATH_UNSAFE` | A managed Skill path is a symbolic link |
|
||||
| `SKILL_READ_FAILED` | An installed Skill file could not be read |
|
||||
| `SKILL_WRITE_FAILED` | The bundled Skill could not be installed |
|
||||
| `ERROR` | Unexpected runtime failure |
|
||||
|
||||
## Codex
|
||||
|
||||
@@ -126,6 +126,8 @@ test("CI and release tracking follow the new source owners", () => {
|
||||
assert.ok(ci.includes(`'${input}'`), `CI misses ${input}`);
|
||||
}
|
||||
assert.ok(read("scripts/release.mjs").includes('"crates/dbx-driver-mongodb/src/mongo_shell.rs"'));
|
||||
assert.ok(read("scripts/release.mjs").includes('"skills/dbx/"'));
|
||||
assert.ok(ci.includes("'skills/dbx/**'"));
|
||||
});
|
||||
|
||||
test("the standalone DuckDB lockfile includes core's internal dependency closure", () => {
|
||||
|
||||
@@ -42,6 +42,7 @@ const PACKAGE_RELEASE_PATHS = [
|
||||
"packages/mcp-win32-x64/",
|
||||
"crates/dbx-mcp/",
|
||||
"crates/dbx-cli/",
|
||||
"skills/dbx/",
|
||||
"crates/dbx-driver-mongodb/src/mongo_shell.rs",
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
|
||||
+29
-151
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: dbx
|
||||
version: 1.0.0
|
||||
description: "DBX CLI for database schema exploration and read-only queries. When the user needs to list connections, explore tables, describe schemas, run queries, or generate AI-friendly schema context from DBX-managed databases. Do NOT use for write operations unless the user explicitly confirms with --allow-writes."
|
||||
version: 1.1.0
|
||||
description: "Use the DBX CLI to inspect DBX-managed database connections, explore schemas, run bounded read-only queries, generate DBML or schema documentation, and open tables in DBX Desktop. Use when the user asks to work with a database configured in DBX from a shell-capable AI agent. Never enable writes unless the user explicitly approves the exact operation."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["dbx"]
|
||||
@@ -10,162 +10,40 @@ metadata:
|
||||
|
||||
# DBX CLI
|
||||
|
||||
> **Prerequisite:** DBX Desktop must be installed and configured with at least one connection. Run `dbx doctor` to verify setup. Install CLI: `npm install -g @dbx-app/cli`
|
||||
Use `dbx` as the database execution boundary. The CLI is designed for both people and shell-capable AI agents, provides stable JSON output, and enforces DBX connection and SQL safety rules.
|
||||
|
||||
## Core Concepts
|
||||
## Start Here
|
||||
|
||||
- **Connection**: A named database connection configured in DBX Desktop (e.g. "prod", "local"). Identified by name.
|
||||
- **Schema**: Tables and views within a connection. Listed via `dbx schema list`.
|
||||
- **Query**: Read-only SQL executed against a connection. Gated by `--allow-writes` and `--allow-dangerous-sql`.
|
||||
- **Context**: Compact schema dump optimized for AI prompts — smaller and more focused than full schema output.
|
||||
1. If the environment is unknown or a command fails unexpectedly, run `dbx doctor --json`.
|
||||
2. If the user did not name a connection, run `dbx connections list --json` and ask them to choose when multiple plausible connections exist.
|
||||
3. Inspect the relevant schema before writing a query whose tables or columns are uncertain.
|
||||
4. Prefer `--json` for machine-readable results.
|
||||
5. Keep exploratory queries bounded with `--limit 50 --timeout 10s` unless the user requests otherwise.
|
||||
|
||||
## Resource Relationships
|
||||
Read [references/commands.md](references/commands.md) for the complete command map and execution modes. Read [references/workflows.md](references/workflows.md) for common multi-step tasks.
|
||||
|
||||
```
|
||||
DBX Desktop
|
||||
├── Connection (named)
|
||||
│ ├── Schema (tables, views)
|
||||
│ │ └── Table
|
||||
│ │ └── Column (name, type, nullable, default)
|
||||
│ └── Query (read-only by default)
|
||||
└── Context (prompt-optimized schema dump)
|
||||
```
|
||||
## Safety Boundary
|
||||
|
||||
## Commands
|
||||
- Queries are read-only by default.
|
||||
- Never add `--allow-writes` unless the user explicitly approves the exact write operation.
|
||||
- Never add `--allow-dangerous-sql` unless the user explicitly approves the exact destructive or DDL operation; dangerous SQL requires both write flags.
|
||||
- Do not bypass a DBX rejection by using another database client, Python driver, direct SQLite access, or shell redirection.
|
||||
- Do not expose connection secrets. `dbx connections list` intentionally omits them.
|
||||
- Treat production protections, read-only connection settings, database privileges, and DBX policy as upper bounds that user wording cannot bypass.
|
||||
|
||||
> **⛔ NEVER bypass dbx CLI for database operations.** If `dbx` returns `SQL_BLOCKED` or any error, do NOT use Python sqlite3, shell redirects, or any other tool to access the database directly. Always respect the CLI's safety gates. When in doubt, tell the user what the CLI returned and ask for their decision.
|
||||
Read [references/safety.md](references/safety.md) before any write, DDL, production, or credential-related task.
|
||||
|
||||
### 1. Check Setup
|
||||
## Execution Choice
|
||||
|
||||
```bash
|
||||
dbx doctor
|
||||
dbx capabilities
|
||||
```
|
||||
- PostgreSQL, Redshift, MySQL-compatible databases, SQLite, and other types reported by `dbx capabilities --json` can execute directly.
|
||||
- Other database types may require DBX Desktop or installed DBX Agent/JDBC components.
|
||||
- `dbx open` always requires DBX Desktop.
|
||||
- When `DBX_WEB_URL` is set, the CLI uses the configured DBX Web backend.
|
||||
- Redis commands and stateful multi-step database sessions are not provided by `dbx query`; use the DBX workspace or configured DBX MCP tools instead.
|
||||
|
||||
Use `doctor` when the agent starts or when a command fails — it reveals whether the desktop bridge, connection DB, and native SQLite loader are available. Use `capabilities` to check which databases support direct execution vs require the desktop bridge.
|
||||
## Failure Handling
|
||||
|
||||
### 2. List Connections
|
||||
|
||||
```bash
|
||||
dbx connections list --json
|
||||
```
|
||||
|
||||
Returns connections without exposing secrets. Parse JSON to present a clean list to the user. Always run this first before any schema or query operation — the user might not remember connection names.
|
||||
|
||||
### 3. Explore Schema
|
||||
|
||||
```bash
|
||||
# List all tables in a connection
|
||||
dbx schema list <connection> --json
|
||||
|
||||
# Describe a specific table
|
||||
dbx schema describe <connection> <table> --json
|
||||
```
|
||||
|
||||
Use `schema list` to survey what's available, then `schema describe` on specific tables the user asks about. Present column names, types, and nullability clearly.
|
||||
|
||||
### 4. Execute Queries
|
||||
|
||||
```bash
|
||||
# Read-only query (default)
|
||||
dbx query <connection> "SELECT ..." --json
|
||||
|
||||
# From file
|
||||
dbx query <connection> --file ./query.sql --json
|
||||
|
||||
# With row limit and timeout
|
||||
dbx query <connection> "SELECT ..." --limit 50 --timeout 10s --json
|
||||
```
|
||||
|
||||
**CRITICAL — Read-only by default.** Write operations (INSERT/UPDATE/DELETE) require `--allow-writes`. Dangerous SQL (DROP/TRUNCATE/ALTER) requires BOTH `--allow-writes` AND `--allow-dangerous-sql`. Never add these flags unless the user explicitly confirms a write operation.
|
||||
|
||||
If the SQL starts with a dash, separate with `--`:
|
||||
```bash
|
||||
dbx query local --json -- "-- comment
|
||||
select 1"
|
||||
```
|
||||
|
||||
### 5. Generate Context for Prompts
|
||||
|
||||
```bash
|
||||
# Full schema context
|
||||
dbx context <connection>
|
||||
|
||||
# Filtered to specific tables
|
||||
dbx context <connection> --tables users,orders,products
|
||||
```
|
||||
|
||||
Use `context` when the user wants to write a query but needs schema reference first. Pipe the output directly into the prompt — it's designed for this. Prefer `--tables` to limit scope and save tokens.
|
||||
|
||||
### 6. Default Connection
|
||||
|
||||
Set `DBX_CONNECTION` to skip the connection argument:
|
||||
|
||||
```bash
|
||||
export DBX_CONNECTION=prod
|
||||
dbx query "SELECT 1" --json
|
||||
dbx context --tables users
|
||||
```
|
||||
|
||||
Detect and use this if set in the environment.
|
||||
|
||||
## Output
|
||||
|
||||
| Flag | Use Case |
|
||||
|------|----------|
|
||||
| `--json` | Machine-readable, auto-parsed (always use this) |
|
||||
| `--format csv` | Piping to other CLI tools |
|
||||
|
||||
Errors go to stderr with non-zero exit code. Run `dbx doctor` first if any command fails unexpectedly.
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Meaning | Agent Response |
|
||||
|------|---------|---------------|
|
||||
| `CONNECTION_NOT_FOUND` | Connection name doesn't exist | List available connections with `dbx connections list --json` |
|
||||
| `SQL_BLOCKED` | Write operation attempted without `--allow-writes` | Ask user: "This is a write operation. Confirm?" Never add write flags automatically. |
|
||||
| `DBX_NOT_RUNNING` | Desktop bridge unavailable | Tell user to open DBX Desktop. Check which commands work without bridge via `dbx capabilities`. |
|
||||
| `INVALID_OPTION` | Wrong flag or flag value | Check `dbx --help` and retry |
|
||||
| `ERROR` | Unexpected runtime failure | Run `dbx doctor`, check logs, retry once |
|
||||
|
||||
## Direct vs Bridge Execution
|
||||
|
||||
PostgreSQL, MySQL (and compatible: Doris, StarRocks), SQLite run directly without DBX Desktop. Other database types require the desktop bridge. Check with `dbx capabilities` to confirm.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Wrong connection name** — Always list connections first with `dbx connections list --json` before running schema or query commands. Never assume connection names from conversation context.
|
||||
|
||||
2. **Schema confusion from context pollution** — When the user asks about a table, verify the connection and table exist before running queries. `dbx schema list <conn> --json` is your verification step.
|
||||
|
||||
3. **Write operations by accident** — Never add `--allow-writes` or `--allow-dangerous-sql` unless the user explicitly confirms. When in doubt, ask.
|
||||
|
||||
4. **Missing desktop bridge** — If `dbx open` or bridge-required connections fail, run `dbx doctor` and tell the user to open DBX Desktop. Commands that don't require the bridge: `connections list`, `schema list`, `schema describe`, `query`, `context` (for PostgreSQL/MySQL/SQLite).
|
||||
|
||||
5. **Timeout on large queries** — Always use `--limit 50 --timeout 10s` for exploratory queries. Remove or increase limits only when the user explicitly asks for full results.
|
||||
|
||||
6. **JSON parse errors** — Old DBX versions may not support `--json` on some commands. If JSON output looks malformed, try without `--json` and parse the human-readable output instead.
|
||||
|
||||
## Multi-Step Workflows
|
||||
|
||||
### Explore then Query
|
||||
|
||||
1. `dbx connections list --json` — verify connection exists
|
||||
2. `dbx schema list <conn> --json` — survey available tables
|
||||
3. `dbx schema describe <conn> <table> --json` — understand target table
|
||||
4. `dbx query <conn> "SELECT ..." --limit 50 --timeout 10s --json` — execute
|
||||
5. Present results to user with row count
|
||||
|
||||
### Generate Context then Help Write Query
|
||||
|
||||
1. `dbx context <conn> --tables a,b` — get compact schema
|
||||
2. Read the output, understand relationships
|
||||
3. Draft the SQL, show it to user for review
|
||||
4. `dbx query <conn> "polished sql" --json` — execute after approval
|
||||
|
||||
### Cross-Connection Comparison
|
||||
|
||||
1. `dbx connections list --json` — identify source and target
|
||||
2. `dbx schema describe <source_conn> <table> --json` — get source structure
|
||||
3. `dbx schema describe <target_conn> <table> --json` — get target structure
|
||||
4. Compare and report differences
|
||||
- `CONNECTION_NOT_FOUND`: list connections and use an exact returned name.
|
||||
- `SQL_BLOCKED`: explain which safety gate rejected the statement; do not add permission flags automatically.
|
||||
- `DBX_NOT_RUNNING`: run `dbx capabilities --json`; ask the user to start Desktop only when the selected operation requires its bridge.
|
||||
- Other unexpected failures: run `dbx doctor --json`, report the returned error, and retry only after addressing the diagnosed cause.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# DBX CLI Command Reference
|
||||
|
||||
Use `dbx --help` as the authoritative reference for the installed CLI version.
|
||||
|
||||
## Environment and Connections
|
||||
|
||||
```bash
|
||||
dbx doctor --json
|
||||
dbx capabilities --json
|
||||
dbx connections list --json
|
||||
```
|
||||
|
||||
`doctor` reports connection storage and Desktop bridge health. `capabilities` identifies direct-query and bridge-required database types. Connection listings omit secrets.
|
||||
|
||||
## Schema Inspection
|
||||
|
||||
```bash
|
||||
dbx schema list <connection> --json
|
||||
dbx schema list <connection> --schema <schema> --database <database> --json
|
||||
dbx schema describe <connection> <table> --json
|
||||
dbx schema describe <connection> <table> --schema <schema> --database <database> --json
|
||||
```
|
||||
|
||||
## Queries
|
||||
|
||||
```bash
|
||||
dbx query <connection> "SELECT ..." --limit 50 --timeout 10s --json
|
||||
dbx query <connection> --file ./query.sql --limit 50 --timeout 10s --json
|
||||
```
|
||||
|
||||
Set `DBX_CONNECTION` to omit the connection argument from `query` and `context` commands. If SQL begins with a dash, place `--` before the SQL argument.
|
||||
|
||||
Writes require `--allow-writes`. Dangerous SQL such as `DROP`, `TRUNCATE`, and `ALTER` requires both `--allow-writes` and `--allow-dangerous-sql`.
|
||||
|
||||
## Prompt Context
|
||||
|
||||
```bash
|
||||
dbx context <connection>
|
||||
dbx context <connection> --tables users,orders --max-tables 20
|
||||
```
|
||||
|
||||
Prefer a table filter when the task concerns a known subset of the schema.
|
||||
|
||||
## DBML and Documentation
|
||||
|
||||
```bash
|
||||
dbx dbml <connection> --out schema.dbml
|
||||
dbx dbml <connection> --out schema.dbml --notes dbx-docs.json --tables users,orders
|
||||
dbx docs <connection> --out schema.html --lang en
|
||||
dbx docs <connection> --out schema.html --notes dbx-docs.json --lang zh-CN
|
||||
```
|
||||
|
||||
Both commands accept `--schema`, `--database`, and `--tables`. An explicitly supplied `--notes` file must exist.
|
||||
|
||||
## Desktop Navigation
|
||||
|
||||
```bash
|
||||
dbx open <connection> <table>
|
||||
dbx open <connection> <table> --schema <schema> --database <database> --json
|
||||
```
|
||||
|
||||
This command requires a running DBX Desktop instance.
|
||||
|
||||
## Agent Skill Management
|
||||
|
||||
```bash
|
||||
dbx agent setup
|
||||
dbx agent status
|
||||
dbx agent setup --force
|
||||
dbx agent setup --skills-dir /custom/skills/root
|
||||
```
|
||||
|
||||
The CLI contains the official DBX Skill. `setup` installs or updates its DBX-managed files under `~/.agents/skills/dbx` by default. It refuses to replace unmanaged or locally modified files unless `--force` is supplied. `status` does not modify files.
|
||||
@@ -0,0 +1,33 @@
|
||||
# DBX CLI Safety Rules
|
||||
|
||||
## Read Operations
|
||||
|
||||
- Prefer explicit columns over `SELECT *` when the schema is known.
|
||||
- Use a row limit and timeout for exploratory queries.
|
||||
- Confirm the connection and database before querying when the user's target is ambiguous.
|
||||
- Do not print connection passwords, tokens, or encrypted secret payloads.
|
||||
|
||||
## Write Operations
|
||||
|
||||
Before adding `--allow-writes`:
|
||||
|
||||
1. Show or summarize the exact statement and target connection.
|
||||
2. Obtain explicit approval for that operation.
|
||||
3. Verify the statement has a narrow predicate when modifying or deleting rows.
|
||||
4. Preserve the CLI's row, timeout, production, read-only connection, and database privilege checks.
|
||||
|
||||
Do not interpret general statements such as "do whatever is needed" as approval for an unspecified database write.
|
||||
|
||||
## Dangerous SQL and DDL
|
||||
|
||||
`DROP`, `TRUNCATE`, `ALTER`, and equivalent dangerous operations require both `--allow-writes` and `--allow-dangerous-sql`. Obtain explicit approval for the exact operation and object before using either flag.
|
||||
|
||||
## Blocked Operations
|
||||
|
||||
If DBX returns `SQL_BLOCKED`, a production restriction, or a read-only error:
|
||||
|
||||
- Do not switch to another client or driver to bypass the restriction.
|
||||
- Do not rewrite the statement to conceal its risk classification.
|
||||
- Explain the rejection and ask the user to choose a permitted alternative.
|
||||
|
||||
Explicit transaction statements and stateful multi-step sessions are not supported by `dbx query`. Use DBX MCP stateful sessions or the DBX editor when the task genuinely requires a pinned session.
|
||||
@@ -0,0 +1,41 @@
|
||||
# DBX CLI Workflows
|
||||
|
||||
## Explore Then Query
|
||||
|
||||
1. Use the connection named by the user. If none was named, run `dbx connections list --json`.
|
||||
2. Run `dbx schema list <connection> --json` when the relevant tables are unknown.
|
||||
3. Run `dbx schema describe <connection> <table> --json` for tables used by the query.
|
||||
4. Draft a read-only statement using verified table and column names.
|
||||
5. Execute with `--limit 50 --timeout 10s --json` unless the user requested different bounds.
|
||||
6. Report the query, connection, row count, truncation state, and relevant results.
|
||||
|
||||
## Generate Prompt Context
|
||||
|
||||
1. Identify the smallest relevant table set.
|
||||
2. Run `dbx context <connection> --tables table_a,table_b`.
|
||||
3. Use the returned schema context to write or review SQL.
|
||||
4. Execute the resulting query only if the user requested execution.
|
||||
|
||||
## Compare Schemas
|
||||
|
||||
1. Describe the source table with `dbx schema describe`.
|
||||
2. Describe the target table separately.
|
||||
3. Compare names, types, nullability, defaults, and ordering.
|
||||
4. Report differences before proposing migration SQL.
|
||||
5. Treat migration execution as a separate write or DDL approval.
|
||||
|
||||
## Generate DBML or HTML Documentation
|
||||
|
||||
1. List tables when the requested scope is unclear.
|
||||
2. Use `--tables` to avoid documenting unrelated objects.
|
||||
3. Use `dbx dbml` for editable relationship modeling.
|
||||
4. Use `dbx docs` for standalone HTML documentation.
|
||||
5. Supply `--notes` only when the requested annotation file exists.
|
||||
|
||||
## Recover From a Failure
|
||||
|
||||
1. Preserve the original error code and message.
|
||||
2. Run `dbx doctor --json` for environment or bridge failures.
|
||||
3. Run `dbx capabilities --json` when direct-versus-bridge support is unclear.
|
||||
4. Correct the connection, option, or environment issue.
|
||||
5. Retry once; if it still fails, report the remaining blocker without bypassing DBX.
|
||||
Reference in New Issue
Block a user