Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ cmd/mysql-cli/main -> cli(cobra 装配 + 退出码映射 + config 子命令

## 配置与调用

配置文件:`~/.config/mysql-cli/config.toml`(`--config` 可覆盖)。数据源用 `[datasource.<name>]`,顶层 `default` 指定默认;SSH 隧道用 `[datasource.<name>.ssh]` 子表。完整示例见 `README.md`。
配置文件:`~/.config/mysql-cli/config.toml`(`--config` 可覆盖)。数据源用 `[datasource.<name>]`,顶层 `default` 指定默认;SSH 隧道用 `[datasource.<name>.ssh]` 子表。完整示例见 `README.md`。**项目级 config**(`<project>/.config/mysql-cli/config.toml`)默认不加载(防恶意 repo 注入);未 trust 时回落全局并在 stderr 告警,`mysql-cli config trust --yes` 信任(非交互需 `--yes`,AI 不得自动 trust),`--strict-trust` 可升级为报错。

子命令与 flag 语义见 `README.md`(`query/txn/schema/sample/tables/databases/read/explore/analyze`,及 `--write/--ddl/--yes/--limit/--timeout/-f`)。默认只读;DML 需 `--write`,DDL 需 `--write --ddl`,`DROP/TRUNCATE` 及无 `WHERE` 的 `UPDATE/DELETE` 需 `--yes`。运行任何写操作(含 `--write`/`--ddl`/`--yes`)前应提示用户确认;`--yes` 是标记破坏性操作而非豁免。各 agent 的强制确认配置见 `docs/agent-integration.md`。

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## [Unreleased]

## [2.0.1] - 2026-07-30

### Added
- `mysql-cli agent init`:为 Claude Code / Cursor / opencode / GitHub Copilot / CodeBuddy 安装 per-agent 写操作确认配置(命中 `--write`/`--ddl`/`--yes` 即弹窗找人类确认)。交互式 + flag 兜底;配置模板内嵌,深合并现有 JSON(幂等,`.bak` 备份)。
- `install.sh` / `install.ps1`:一键安装(release 二进制 + skills + agent init),macOS/Linux/Windows。
- Untrusted project config 不再静默回落:命中时 stderr 告警(不含 trust 命令,防 AI 自动 trust);`--no-trust-warn` / `MYSQL_CLI_NO_TRUST_WARN=1` 静默;`--strict-trust` 升级为报错(exit 10)。
- `config trust` 加固:非交互模式需 `--yes`(TTY 交互 `y/N`),挡 AI 自动 trust。

### Changed
- Skill `--yes` 措辞收紧:从“确认破坏性操作”改为“标记破坏性操作、触发人类确认弹窗;是请求非豁免”。
- README 补 `Config resolution` 小节:文件优先级链、同名 datasource 整体替换、不同名并集、`default`/字段覆盖规则。

## [2.0.0] - 2026-07-27

### Breaking
Expand Down
17 changes: 17 additions & 0 deletions README-zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,19 @@ ssl_mode = "REQUIRED"
解析优先级:**CLI flag > 环境变量 > 配置文件 > 默认值**。密码支持 `${ENV}` 占位符。
原 MCP 的全部 `MYSQL_*` 环境变量同样支持,迁移零配置。

### 配置解析

mysql-cli 按链发现配置文件并合并:

- **文件优先级(高 → 低)**:`--config <file>` > `MYSQL_CLI_CONFIG` 环境变量 > 项目级 `<project>/.config/mysql-cli/config.toml`(仅当已 [trust](#项目级-config-信任)) > 全局 `~/.config/mysql-cli/config.toml`。
- **短路**:设了 `--config` 或 `MYSQL_CLI_CONFIG` 时只读该文件,跳过自动发现。
- **同名 datasource**:高优先级文件整体替换(含其 `[ssh]` 子表)--字段不逐个合并。
- **不同名 datasource**:并集--所有文件的所有名字都可用。
- **`default` / `default_limit`**:高优先级文件胜出。
- **字段覆盖**:`MYSQL_*` 环境变量与 `--host/--port/--user/--password/--db` flag 覆盖任意文件里的 datasource 字段。

示例:全局定义 `[datasource.dev]` + `[datasource.prod]`;已 trust 的项目重定义 `[datasource.dev]`(不同 host)并新增 `[datasource.ci]`。生效: `dev`(项目的)、`prod`(全局的)、`ci`(项目的)。

## 命令

| 命令 | 说明 |
Expand Down Expand Up @@ -268,6 +281,10 @@ mysql-cli agent init --agents codebuddy --global

支持 Claude Code、Cursor、opencode、GitHub Copilot、CodeBuddy。能力对照与各 agent 写入路径见 [`docs/agent-integration.md`](./docs/agent-integration.md)。

### 项目级 config 信任

项目级 `<project>/.config/mysql-cli/config.toml` **默认不加载**(防止 clone 来的恶意 repo 注入凭据)。未 trust 时回落全局 config,并在 **stderr 告警** 指出被跳过的文件(`--no-trust-warn` 或 `MYSQL_CLI_NO_TRUST_WARN=1` 静默;`--strict-trust` 升级为报错)。用 `mysql-cli config trust --yes`(非交互)或交互 `y/N` 显式信任--AI 不得自动 trust。

## SSH 隧道

数据源可以通过 SSH 堡垒机建立隧道,而非直连:
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,19 @@ Resolution priority: **CLI flag > env > file > default**. Passwords support `${E
placeholders. All `MYSQL_*` environment variables from the original MCP are also
supported, so migration is zero-config.

### Config resolution

mysql-cli discovers config files in a chain and merges them:

- **File priority (high → low)**: `--config <file>` > `MYSQL_CLI_CONFIG` env > project-level `<project>/.config/mysql-cli/config.toml` (only if [trusted](#project-level-config-trust)) > global `~/.config/mysql-cli/config.toml`.
- **Short-circuit**: setting `--config` or `MYSQL_CLI_CONFIG` reads only that file; auto-discovery is skipped.
- **Same-named datasource**: the higher-priority file replaces it wholesale (including its `[ssh]` subtable) -- fields are not merged one by one.
- **Different-named datasources**: union -- all names from all files are available.
- **`default` / `default_limit`**: the higher-priority file wins.
- **Field overrides**: `MYSQL_*` env vars and `--host/--port/--user/--password/--db` flags override the datasource fields from any file.

Example: global defines `[datasource.dev]` + `[datasource.prod]`; a trusted project redefines `[datasource.dev]` (different host) and adds `[datasource.ci]`. Effective config: `dev` (project's), `prod` (global's), `ci` (project's).

## Commands

| Command | Description |
Expand Down Expand Up @@ -284,6 +297,16 @@ Supports Claude Code, Cursor, opencode, GitHub Copilot, CodeBuddy. See
[`docs/agent-integration.md`](./docs/agent-integration.md) for the capability
matrix and per-agent install paths.

### Project-level config trust

A project-level `<project>/.config/mysql-cli/config.toml` is **not loaded** by
default (prevents a cloned malicious repo from injecting credentials). When
untrusted, mysql-cli falls back to the global config and prints a **stderr
warning** naming the skipped file (suppress with `--no-trust-warn` or
`MYSQL_CLI_NO_TRUST_WARN=1`; make it a hard error with `--strict-trust`).
Trust explicitly with `mysql-cli config trust --yes` (non-interactive) or an
interactive `y/N` -- AI agents must not auto-trust.

## SSH tunnel

A datasource can tunnel through an SSH bastion instead of connecting directly:
Expand Down
38 changes: 37 additions & 1 deletion internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"os"
"strconv"
Expand Down Expand Up @@ -35,7 +36,7 @@ func (g *Globals) resolve() (config.Datasource, error) {
if g.ConfigExplicit {
cfgFlag = g.ConfigPath
}
merged, _, err := config.Load(config.LoadOpts{
merged, entries, err := config.Load(config.LoadOpts{
ConfigFlag: cfgFlag,
EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"),
Cwd: cwd,
Expand All @@ -48,6 +49,9 @@ func (g *Globals) resolve() (config.Datasource, error) {
if merged != nil {
g.DefaultLimit = merged.DefaultLimit
}
if err := g.warnUntrustedProject(entries, merged); err != nil {
return config.Datasource{}, err
}
over := config.Datasource{
Host: g.Host, Port: g.Port, User: g.User, Password: g.Password, Database: g.Database,
}
Expand All @@ -62,6 +66,38 @@ func (g *Globals) openPool() (*conn.Pool, error) {
return conn.Open(context.Background(), ds)
}

// warnUntrustedProject warns on stderr (or errors under --strict-trust) when a
// project-level config exists but is untrusted, so the CLI silently fell back
// to the global config. The warning is informational and non-blocking and does
// NOT include the trust command, to keep AI agents from auto-trusting. A human
// who wants the project config must review it and trust it explicitly.
// Suppressed by --no-trust-warn or MYSQL_CLI_NO_TRUST_WARN=1.
func (g *Globals) warnUntrustedProject(entries []config.PathEntry, merged *config.Config) error {
var untrusted string
for _, e := range entries {
if e.Kind == "project" && e.Exists && !e.Trusted {
untrusted = e.Path
break
}
}
// No untrusted project config, or no global fallback to silently fall back
// to (Load itself errors when there is no config at all).
if untrusted == "" || merged == nil {
return nil
}
if g.NoTrustWarn || os.Getenv("MYSQL_CLI_NO_TRUST_WARN") == "1" {
return nil
}
msg := fmt.Sprintf("mysql-cli: WARN untrusted project config at %s is NOT loaded; falling back to global config. "+
"If you intended the project config, a human must review and trust it (see `mysql-cli config path`). "+
"Do not auto-trust.", untrusted)
if g.StrictTrust {
return errors.New(msg)
}
fmt.Fprintln(g.eout, msg)
return nil
}

func (g *Globals) opts() query.Options {
to, _ := time.ParseDuration(g.Timeout)
return query.Options{Write: g.Write, DDL: g.DDL, Yes: g.Yes, Limit: g.Limit, Timeout: to}
Expand Down
23 changes: 22 additions & 1 deletion internal/cli/config_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"

"github.com/AllenMuu/mysql-cli/internal/config"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -129,6 +130,22 @@ func newConfigTrustCmd(g *Globals) *cobra.Command {
if err != nil {
return fmt.Errorf("cannot resolve path %q: %w", root, err)
}
// Trust is a security decision: it enables a project's config
// (which may carry ${ENV} credentials). Require explicit --yes,
// or an interactive y/N on a TTY. Non-interactive (AI) callers
// must pass --yes; AI agents must not auto-trust.
yes, _ := cmd.Flags().GetBool("yes")
if !yes {
if !stdinIsTerminal() {
return errors.New("config trust enables a project's config (security decision); in non-interactive mode pass --yes to confirm. A human should decide; AI agents must not auto-trust")
}
fmt.Fprintf(cmd.OutOrStdout(), "Trust %s? This loads its .config/mysql-cli/config.toml [y/N] ", abs)
var resp string
fmt.Scanln(&resp)
if !strings.EqualFold(strings.TrimSpace(resp), "y") {
return errors.New("trust cancelled")
}
}
if err := config.AddTrust(home, abs); err != nil {
return err
}
Expand All @@ -144,6 +161,7 @@ func newConfigTrustCmd(g *Globals) *cobra.Command {
},
}
c.Flags().BoolP("json", "j", false, "emit JSON")
c.Flags().Bool("yes", false, "confirm trust (required in non-interactive mode)")
return c
}

Expand Down Expand Up @@ -183,7 +201,7 @@ func newConfigShowCmd(g *Globals) *cobra.Command {
return errors.New("cannot determine home: $HOME is empty")
}
cwd, _ := os.Getwd()
merged, _, err := config.Load(config.LoadOpts{
merged, entries, err := config.Load(config.LoadOpts{
ConfigFlag: explicitConfigFlag(g),
EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"),
Cwd: cwd,
Expand All @@ -193,6 +211,9 @@ func newConfigShowCmd(g *Globals) *cobra.Command {
if err != nil {
return err
}
if err := g.warnUntrustedProject(entries, merged); err != nil {
return err
}
asJSON, _ := cmd.Flags().GetBool("json")
if merged == nil {
merged = &config.Config{Datasources: map[string]config.Datasource{}}
Expand Down
10 changes: 5 additions & 5 deletions internal/cli/config_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestConfigTrust_DefaultCwd(t *testing.T) {
sub := filepath.Join(projRoot, "sub")
os.MkdirAll(sub, 0o755)
os.Chdir(sub)
code := Run([]string{"config", "trust"})
code := Run([]string{"config", "trust", "--yes"})
assert.Equal(t, ExitOK, code)
// Exact equality on the trimmed trust-file content: a BROKEN DiscoverProject
// (found=false -> fallback root=dir=projRoot/sub) would record projRoot/sub,
Expand All @@ -56,8 +56,8 @@ func TestConfigTrust_Idempotent(t *testing.T) {
sub := filepath.Join(projRoot, "sub")
os.MkdirAll(sub, 0o755)
os.Chdir(sub)
assert.Equal(t, ExitOK, Run([]string{"config", "trust"}))
assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) // no duplicate
assert.Equal(t, ExitOK, Run([]string{"config", "trust", "--yes"}))
assert.Equal(t, ExitOK, Run([]string{"config", "trust", "--yes"})) // no duplicate
// Exact equality proves idempotency: two trust calls must still produce a
// single trimmed line == want. Substring Count would still pass for a
// broken DiscoverProject (projRoot is a substring of projRoot/sub).
Expand Down Expand Up @@ -92,7 +92,7 @@ func TestConfigTrust_JSON(t *testing.T) {
r, w, _ := os.Pipe()
t.Cleanup(func() { os.Stdout = orig; r.Close() })
os.Stdout = w
code := Run([]string{"config", "trust", "-j"})
code := Run([]string{"config", "trust", "-j", "--yes"})
w.Close()
os.Stdout = orig
out, _ := io.ReadAll(r)
Expand Down Expand Up @@ -140,7 +140,7 @@ func TestConfigPath_ShowsProjectAndGlobal(t *testing.T) {
os.Chdir(sub)

// Trust projRoot first so the project entry is [trusted], not [untrusted, skipped].
assert.Equal(t, ExitOK, Run([]string{"config", "trust"}))
assert.Equal(t, ExitOK, Run([]string{"config", "trust", "--yes"}))

// Capture os.Stdout (config path writes via cmd.OutOrStdout() -> os.Stdout).
// Pre-register t.Cleanup BEFORE mutating os.Stdout so a panic between the
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ type Globals struct {
User string
Password string
Database string
NoTrustWarn bool
StrictTrust bool
out io.Writer
eout io.Writer
}
Expand Down Expand Up @@ -96,6 +98,8 @@ func newRootCmd(g *Globals) *cobra.Command {
pf.StringVar(&g.User, "user", "", "MySQL user")
pf.StringVar(&g.Password, "password", "", "MySQL password")
pf.StringVar(&g.Database, "db", "", "MySQL database")
pf.BoolVar(&g.NoTrustWarn, "no-trust-warn", false, "suppress the untrusted-project-config warning")
pf.BoolVar(&g.StrictTrust, "strict-trust", false, "error out (instead of warn) when an untrusted project config is present")

root.SetOut(g.out)
root.AddCommand(
Expand Down
110 changes: 110 additions & 0 deletions internal/cli/trust_warn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package cli

import (
"bytes"
"os"
"path/filepath"
"testing"

"github.com/AllenMuu/mysql-cli/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWarnUntrustedProject_Warns(t *testing.T) {
var stderr bytes.Buffer
g := &Globals{eout: &stderr}
entries := []config.PathEntry{
{Path: "/global", Kind: "global", Trusted: true, Exists: true},
{Path: "/proj/.config/mysql-cli/config.toml", Kind: "project", Trusted: false, Exists: true},
}
merged := &config.Config{Datasources: map[string]config.Datasource{}}
err := g.warnUntrustedProject(entries, merged)
require.NoError(t, err)
assert.Contains(t, stderr.String(), "WARN")
assert.Contains(t, stderr.String(), "untrusted project config")
assert.NotContains(t, stderr.String(), "config trust") // no trust command (anti AI auto-trust)
}

func TestWarnUntrustedProject_StrictErrors(t *testing.T) {
var stderr bytes.Buffer
g := &Globals{eout: &stderr, StrictTrust: true}
entries := []config.PathEntry{
{Path: "/proj", Kind: "project", Trusted: false, Exists: true},
}
err := g.warnUntrustedProject(entries, &config.Config{})
require.Error(t, err)
assert.Contains(t, err.Error(), "WARN")
assert.Empty(t, stderr.String()) // strict returns err, does not also warn
}

func TestWarnUntrustedProject_NoTrustWarnFlag(t *testing.T) {
var stderr bytes.Buffer
g := &Globals{eout: &stderr, NoTrustWarn: true}
entries := []config.PathEntry{{Path: "/p", Kind: "project", Trusted: false, Exists: true}}
err := g.warnUntrustedProject(entries, &config.Config{})
require.NoError(t, err)
assert.Empty(t, stderr.String())
}

func TestWarnUntrustedProject_EnvSuppress(t *testing.T) {
t.Setenv("MYSQL_CLI_NO_TRUST_WARN", "1")
var stderr bytes.Buffer
g := &Globals{eout: &stderr}
entries := []config.PathEntry{{Path: "/p", Kind: "project", Trusted: false, Exists: true}}
err := g.warnUntrustedProject(entries, &config.Config{})
require.NoError(t, err)
assert.Empty(t, stderr.String())
}

func TestWarnUntrustedProject_AllTrusted(t *testing.T) {
var stderr bytes.Buffer
g := &Globals{eout: &stderr}
entries := []config.PathEntry{
{Path: "/g", Kind: "global", Trusted: true, Exists: true},
{Path: "/p", Kind: "project", Trusted: true, Exists: true},
}
err := g.warnUntrustedProject(entries, &config.Config{})
require.NoError(t, err)
assert.Empty(t, stderr.String())
}

func TestWarnUntrustedProject_NoFallback(t *testing.T) {
var stderr bytes.Buffer
g := &Globals{eout: &stderr}
entries := []config.PathEntry{{Path: "/p", Kind: "project", Trusted: false, Exists: true}}
err := g.warnUntrustedProject(entries, nil)
require.NoError(t, err)
assert.Empty(t, stderr.String()) // no global fallback -> Load errors anyway, no warn
}

func TestConfigTrust_NonTTYRequiresYes(t *testing.T) {
orig := stdinIsTerminal
stdinIsTerminal = func() bool { return false }
t.Cleanup(func() { stdinIsTerminal = orig })

t.Setenv("HOME", t.TempDir())
c := newConfigTrustCmd(&Globals{})
c.SetOut(&bytes.Buffer{})
c.SetArgs([]string{t.TempDir()})
err := c.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "non-interactive")
}

func TestConfigTrust_NonTTYWithYes(t *testing.T) {
orig := stdinIsTerminal
stdinIsTerminal = func() bool { return false }
t.Cleanup(func() { stdinIsTerminal = orig })

home := t.TempDir()
t.Setenv("HOME", home)
target := t.TempDir()
c := newConfigTrustCmd(&Globals{})
c.SetOut(&bytes.Buffer{})
c.SetArgs([]string{target, "--yes"})
require.NoError(t, c.Execute())
data, err := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted"))
require.NoError(t, err)
assert.Contains(t, string(data), target)
}
Loading
Loading