diff --git a/AGENTS.md b/AGENTS.md index 7101d82..9a64c21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ cmd/mysql-cli/main -> cli(cobra 装配 + 退出码映射 + config 子命令 配置文件:`~/.config/mysql-cli/config.toml`(`--config` 可覆盖)。数据源用 `[datasource.]`,顶层 `default` 指定默认;SSH 隧道用 `[datasource..ssh]` 子表。完整示例见 `README.md`。 -子命令与 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`。 +子命令与 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`。 ## Skill 体系(对接 AI agent) @@ -81,3 +81,11 @@ mysql-cli 的 skill 不再自研安装,而是接入 [vercel-labs/skills](https:/ - **格式校验**:`scripts/skill-format-check.sh` 校验 SKILL.md frontmatter(name/version/description/metadata + semver),CI `.github/workflows/skill-format-check.yml` PR 时强制。改 skill 后本地跑一遍。 - **版本真相源**:skill 版本 = 仓库 `skills/*/SKILL.md` frontmatter 的 `version` 字段(不再二进制内嵌)。 - **无 Node fallback**:手动复制仓库 `skills/` 目录到 agent skill 目录。 + +## 写操作人类确认(agent init) + +`--write`/`--ddl`/`--yes` 是 AI 自传的 flag,CLI 内部无人类确认环节。`mysql-cli agent init` 为各 agent 安装配置,在写操作执行前弹窗找人类确认(命中 `--write`/`--ddl`/`--yes` 即拦,只读放行)。 + +- **命令**:`mysql-cli agent init`(交互式选 agent + 层级);非交互 `mysql-cli agent init --agents claude,opencode,copilot --project`。 +- **支持**:claude / cursor / opencode / copilot / codebuddy。不含 Codex(hook 未坐实)、TRAE(规则文件格式未坐实)。 +- **实现**:配置模板内嵌于二进制 `internal/agentsetup/templates/`;合并类配置(settings.json/opencode.json/.vscode/settings.json)深合并进现有文件并备份 `.bak`,幂等。详见 `docs/agent-integration.md`。 diff --git a/README-zh.md b/README-zh.md index a24339f..98565b9 100644 --- a/README-zh.md +++ b/README-zh.md @@ -45,6 +45,14 @@ #### 安装 +**一键脚本**(二进制 + skills + 各 agent 写操作确认配置): + +```bash +curl -fsSL https://raw.githubusercontent.com/AllenMuu/mysql-cli/main/install.sh -o install.sh +bash install.sh # macOS/Linux;直接运行而非 curl|bash,以保留交互提示 +# Windows: .\install.ps1 (仓库根目录) +``` + **方式一 - `npx`(推荐,无需 Go 工具链):** ```bash @@ -248,6 +256,18 @@ ssl_mode = "REQUIRED" 标识符按严格白名单校验(`^[a-zA-Z0-9_$]+$`);多语句输入被拒绝(请用 `txn`)。 只读 / 多语句检查在**打开连接之前**执行,因此 agent 无需触碰数据库即可拿到正确退出码。 +### 写操作的人类确认 + +`--write`/`--yes` 是 AI 自己传的 flag,CLI 自身无法把人类拉进确认环节。`mysql-cli agent init` 为各 agent 安装配置,在写操作执行前弹窗找人类确认: + +```bash +mysql-cli agent init # 交互式:选 agent + 层级 +mysql-cli agent init --agents claude,opencode,copilot --project +mysql-cli agent init --agents codebuddy --global +``` + +支持 Claude Code、Cursor、opencode、GitHub Copilot、CodeBuddy。能力对照与各 agent 写入路径见 [`docs/agent-integration.md`](./docs/agent-integration.md)。 + ## SSH 隧道 数据源可以通过 SSH 堡垒机建立隧道,而非直连: diff --git a/README.md b/README.md index 5547ca3..d843bf9 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,14 @@ binary with **JSON by default** and **stable exit codes**, so any agent #### Install +**One-shot installer** (binary + skills + per-agent write-confirmation configs): + +```bash +curl -fsSL https://raw.githubusercontent.com/AllenMuu/mysql-cli/main/install.sh -o install.sh +bash install.sh # macOS/Linux; run directly so prompts work (not curl|bash) +# Windows: .\install.ps1 (in repo root) +``` + **Option 1 - `npx` (recommended, no Go toolchain needed):** ```bash @@ -260,6 +268,22 @@ multi-statement input is rejected (use `txn`). The read-only / multi-statement checks run **before** a connection is opened, so agents get the right exit code without touching the database. +### Human confirmation for writes + +`--write`/`--yes` are flags the AI passes itself, so the CLI alone can't pull a +human into the loop. `mysql-cli agent init` installs per-agent configs that +prompt a human before any write runs: + +```bash +mysql-cli agent init # interactive: pick agents + scope +mysql-cli agent init --agents claude,opencode,copilot --project +mysql-cli agent init --agents codebuddy --global +``` + +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. + ## SSH tunnel A datasource can tunnel through an SSH bastion instead of connecting directly: diff --git a/docs/agent-integration.md b/docs/agent-integration.md new file mode 100644 index 0000000..0493471 --- /dev/null +++ b/docs/agent-integration.md @@ -0,0 +1,65 @@ +# 多 Agent 写操作确认 + +mysql-cli 默认只读;写操作由 `--write`/`--ddl`/`--yes` 解锁。但这些 flag 是 AI 在命令行里自己传的--`--yes` 原义是“AI 已确认”,不是“找人类确认”。`mysql-cli agent init` 把写操作的确认权从 AI 手里拿回给人类:为各 agent 安装配置,命中写 flag 即弹窗,由用户批准才执行。 + +## 安装 + +```bash +mysql-cli agent init +``` + +交互式选择你用的 agent + 层级(项目/全局)。非交互环境用 flag: + +```bash +mysql-cli agent init --agents claude,opencode,copilot --project +mysql-cli agent init --agents codebuddy --global +mysql-cli agent init --agents cursor --project --dry-run # 预览不写 +``` + +支持的 agent(逗号分隔):`claude` `cursor` `opencode` `copilot` `codebuddy`。 + +## 能力对照 + +| Agent | name | 能力 | 机制 | +|---|---|---|---| +| Claude Code | `claude` | 精确强制 | PreToolUse hook -> `ask` | +| opencode | `opencode` | 精确强制 | `permission.bash` glob + `ask` | +| GitHub Copilot | `copilot` | 精确强制 | `autoApprove` 正则 `false` | +| CodeBuddy | `codebuddy` | 精确强制 | PreToolUse hook(兼容 Claude Code) | +| Cursor | `cursor` | 仅引导 | `.cursor/rules` 注入上下文 | + +> “精确”= 只对含 `--write`/`--ddl`/`--yes` 的命令弹窗,只读放行;“仅引导”= 依赖模型遵守规则,非引擎级闸门。 +> 不含 Codex(hook 未坐实,`.rules` 无法按 flag 精确拦)、TRAE(规则文件格式未坐实)。 + +## 写入位置 + +| Agent | 项目级(`--project`) | 全局(`--global`) | +|---|---|---| +| claude | `.claude/settings.json` + `.claude/hooks/mysql-write-guard.py` | `~/.claude/...` | +| cursor | `.cursor/rules/mysql-cli-write-guard.mdc` | 不支持(IDE 设置) | +| opencode | `opencode.json` | `~/.config/opencode/opencode.json` | +| copilot | `.vscode/settings.json` + `.github/copilot-instructions.md` | VS Code 用户 `settings.json` | +| codebuddy | `.codebuddy/settings.json` + `.codebuddy/hooks/mysql-write-guard.py` | `~/.codebuddy/...` | + +合并类配置(`settings.json` / `opencode.json` / `.vscode/settings.json`)会深合并进现有文件并备份 `.bak`,不破坏既有内容;重复安装会按 command/键去重,幂等。单文件类(`.mdc` / instructions / `.md`)默认跳过已存在文件,`--force` 覆盖。 + +## 验证 + +每个 agent 配置后,新会话里让 AI 跑两条对照: + +- 只读(应**放行**):`mysql-cli query "SELECT 1"` +- 写操作(应**弹窗**):`mysql-cli query "UPDATE t SET a=1 WHERE id=1" --write` + +hook 脚本可单独测: +```bash +echo '{"tool_name":"Bash","tool_input":{"command":"mysql-cli query \"DROP TABLE x\" --write --yes"}}' \ + | python3 .codebuddy/hooks/mysql-write-guard.py +# 期望: {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask",...}} +``` + +## 原理 + +- `--yes` 是 AI 传的 flag,CLI 内部无人类确认环节;`agent init` 装的配置在 agent 执行命令前拦截,把含 `--write`/`--ddl`/`--yes` 的执行交给人类弹窗批准。 +- hook 脚本用 shlex 精确匹配 flag token,SQL 字面量里的 `--write` 文本不会误伤;回退正则只认独立 token,兼容 `bash -c` 包裹。 +- CodeBuddy / Claude Code 依赖其兼容 Claude Code PreToolUse hook(CodeBuddy 已交叉验证;若某版本不认 `ask` JSON,回退 `exit 2` 阻断)。 +- 配置模板内嵌于 mysql-cli 二进制(`internal/agentsetup/templates/`),随版本发布;升级 mysql-cli 后重跑 `agent init` 即可刷新。 diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..fa8abff --- /dev/null +++ b/install.ps1 @@ -0,0 +1,78 @@ +# mysql-cli one-shot installer (Windows / PowerShell): +# 1) download the mysql-cli binary from the latest GitHub release +# 2) install agent skills via `npx skills add` +# 3) install per-agent write-confirmation configs via `mysql-cli agent init` +# +# Run in PowerShell (allow execution for this session): +# Set-ExecutionPolicy -Scope Process Bypass -Force +# .\install.ps1 +$ErrorActionPreference = "Stop" + +$Repo = "AllenMuu/mysql-cli" +$InstallDir = if ($env:MYSQL_CLI_INSTALL_DIR) { $env:MYSQL_CLI_INSTALL_DIR } else { Join-Path $env:USERPROFILE ".local\bin" } + +function Info($m) { Write-Host "==> $m" -ForegroundColor Blue } +function Ok($m) { Write-Host "OK $m" -ForegroundColor Green } +function Warn($m) { Write-Host "!! $m" -ForegroundColor Yellow } +function Err($m) { Write-Host "XX $m" -ForegroundColor Red } + +# ---- 1. binary from the latest GitHub release ---- +Info "Installing mysql-cli binary from latest release..." +$Arch = switch ($env:PROCESSOR_ARCHITECTURE) { + "ARM64" { "arm64" } + default { "amd64" } +} +$Archive = "mysql-cli_windows_$Arch.zip" +$Url = "https://github.com/$Repo/releases/latest/download/$Archive" +$Tmp = Join-Path ([IO.Path]::GetTempPath()) ("mysql-cli-" + [guid]::NewGuid().ToString()) +New-Item -ItemType Directory -Force -Path $Tmp | Out-Null +try { + try { + Invoke-WebRequest -Uri $Url -OutFile (Join-Path $Tmp $Archive) -UseBasicParsing + } catch { + Err "download failed: $Url" + Err "alternative: go install github.com/AllenMuu/mysql-cli/cmd/mysql-cli@latest" + exit 1 + } + Expand-Archive -Path (Join-Path $Tmp $Archive) -DestinationPath $Tmp -Force + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + Move-Item -Force (Join-Path $Tmp "mysql-cli.exe") (Join-Path $InstallDir "mysql-cli.exe") + Ok "binary -> $(Join-Path $InstallDir 'mysql-cli.exe')" +} finally { + Remove-Item -Recurse -Force $Tmp -ErrorAction SilentlyContinue +} +$Bin = Join-Path $InstallDir "mysql-cli.exe" + +if ($env:PATH -notlike "*$InstallDir*") { + Warn "$InstallDir not in PATH; add it or invoke via full path." +} + +# ---- 2. agent skills ---- +Info "Installing skills (npx skills add)..." +if (Get-Command npx -ErrorAction SilentlyContinue) { + if (-not [Console]::IsInputRedirected) { + npx --yes skills add AllenMuu/mysql-cli + } else { + Warn "non-interactive shell; run later: npx skills add AllenMuu/mysql-cli" + } +} else { + Warn "npx not found; install Node, then: npx skills add AllenMuu/mysql-cli" +} + +# ---- 3. per-agent write-confirmation configs ---- +Info "Installing write-confirmation configs (agent init)..." +& $Bin agent init --help 2>$null | Out-Null +if ($LASTEXITCODE -eq 0) { + if (-not [Console]::IsInputRedirected) { + & $Bin agent init + } else { + Warn "non-interactive shell; run later: $Bin agent init" + } +} else { + Warn "this release has no 'agent init' (needs v2.1+); re-run after upgrading." + Warn "manual configs: https://github.com/$Repo/blob/main/docs/agent-integration.md" +} + +Write-Host "" +Ok "Done. Next: $Bin config init --global # then edit ~/.config/mysql-cli/config.toml" +Ok "Verify: $Bin query 'SELECT 1'" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3b91a8d --- /dev/null +++ b/install.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# mysql-cli one-shot installer (macOS / Linux): +# 1) download the mysql-cli binary from the latest GitHub release +# 2) install agent skills via `npx skills add` +# 3) install per-agent write-confirmation configs via `mysql-cli agent init` +# +# Run it directly, not via `curl | bash`, so the interactive skills and +# agent-init prompts keep their TTY: +# curl -fsSL https://raw.githubusercontent.com/AllenMuu/mysql-cli/main/install.sh -o install.sh +# bash install.sh +set -euo pipefail + +REPO="AllenMuu/mysql-cli" +INSTALL_DIR="${MYSQL_CLI_INSTALL_DIR:-$HOME/.local/bin}" + +c_info() { printf '\033[1;34m==>\033[0m %s\n' "$1"; } +c_ok() { printf '\033[1;32mOK\033[0m %s\n' "$1"; } +c_warn() { printf '\033[1;33m!!\033[0m %s\n' "$1"; } +c_err() { printf '\033[1;31mXX\033[0m %s\n' "$1" >&2; } + +# ---- 1. binary from the latest GitHub release ---- +c_info "Installing mysql-cli binary from latest release..." +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) +case "$ARCH" in + x86_64|amd64) ARCH=amd64 ;; + arm64|aarch64) ARCH=arm64 ;; + *) c_err "unsupported architecture: $ARCH"; exit 1 ;; +esac +case "$OS" in + darwin|linux) EXT=tar.gz ;; + *) c_err "unsupported OS: $OS (on Windows use install.ps1)"; exit 1 ;; +esac +ARCHIVE="mysql-cli_${OS}_${ARCH}.${EXT}" +URL="https://github.com/${REPO}/releases/latest/download/${ARCHIVE}" +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +if ! curl -fsSL "$URL" -o "$TMP/$ARCHIVE"; then + c_err "download failed: $URL" + c_err "alternative: go install github.com/AllenMuu/mysql-cli/cmd/mysql-cli@latest" + exit 1 +fi +tar -xzf "$TMP/$ARCHIVE" -C "$TMP" +mkdir -p "$INSTALL_DIR" +mv -f "$TMP/mysql-cli" "$INSTALL_DIR/mysql-cli" +chmod +x "$INSTALL_DIR/mysql-cli" +c_ok "binary -> $INSTALL_DIR/mysql-cli" + +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) c_warn "$INSTALL_DIR not in PATH. Add: export PATH=\"$INSTALL_DIR:\$PATH\"" ;; +esac +BIN="$INSTALL_DIR/mysql-cli" + +# ---- 2. agent skills ---- +c_info "Installing skills (npx skills add)..." +if command -v npx >/dev/null 2>&1; then + if [ -t 0 ]; then + npx --yes skills add AllenMuu/mysql-cli || c_warn "skills install incomplete (non-fatal)" + else + c_warn "non-interactive shell; run later: npx skills add AllenMuu/mysql-cli" + fi +else + c_warn "npx not found; install Node, then: npx skills add AllenMuu/mysql-cli" +fi + +# ---- 3. per-agent write-confirmation configs ---- +c_info "Installing write-confirmation configs (agent init)..." +if "$BIN" agent init --help >/dev/null 2>&1; then + if [ -t 0 ]; then + "$BIN" agent init || c_warn "agent init incomplete (non-fatal)" + else + c_warn "non-interactive shell; run later: $BIN agent init" + fi +else + c_warn "this release has no 'agent init' (needs v2.1+); re-run this script after upgrading." + c_warn "manual configs: https://github.com/${REPO}/blob/main/docs/agent-integration.md" +fi + +echo +c_ok "Done. Next: $BIN config init --global # then edit ~/.config/mysql-cli/config.toml" +c_ok "Verify: $BIN query 'SELECT 1'" diff --git a/internal/agentsetup/agentsetup.go b/internal/agentsetup/agentsetup.go new file mode 100644 index 0000000..0ebfa84 --- /dev/null +++ b/internal/agentsetup/agentsetup.go @@ -0,0 +1,393 @@ +// Package agentsetup installs per-agent write-confirmation configs for +// mysql-cli. It is the engine behind `mysql-cli agent init`: each supported +// agent declares the files it needs (write-new, merge-into-JSON, or copy a +// hook script), and Install materializes them at the project or global scope. +package agentsetup + +import ( + _ "embed" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" +) + +//go:embed templates/mysql-write-guard.py +var hookScript []byte + +//go:embed templates/cursor-rule.mdc +var cursorRule string + +//go:embed templates/copilot-instructions.md +var copilotInstructions string + +//go:embed templates/codebuddy.md +var codebuddyRule string + +// Capability classifies how forcefully an agent guards writes. +type Capability int + +const ( + CapEnforce Capability = iota // engine-level gate (hook/permission/regex) + CapGuide // context instruction only +) + +func (c Capability) String() string { + if c == CapEnforce { + return "enforce" + } + return "guide" +} + +// Scope selects where configs are written. +type Scope int + +const ( + ScopeProject Scope = iota + ScopeGlobal +) + +func (s Scope) String() string { + if s == ScopeGlobal { + return "global" + } + return "project" +} + +// InstallOpts carries scope, target dirs, and behavior flags. +type InstallOpts struct { + Scope Scope + Force bool // overwrite single-file configs that already exist + DryRun bool // describe actions, write nothing + ProjectDir string // cwd; used for ScopeProject + Home string // home dir; used for ScopeGlobal +} + +// fileAction says how a step touches its target file. +type fileAction int + +const ( + actionWriteFile fileAction = iota // write content; skip if exists unless Force + actionMergeJSON // deep-merge fragment into existing JSON (backup .bak) + actionCopyScript // write executable script (overwrite) +) + +// step is one file operation performed during Install. +type step struct { + path string + action fileAction + content []byte // for writeFile / copyScript + fragment map[string]any // for mergeJSON +} + +// Agent is one supported AI agent. +type Agent struct { + Name string + Desc string + Cap Capability + steps func(InstallOpts) ([]step, error) // err = scope unsupported / unusable +} + +// Agents is the ordered registry of supported agents. +var Agents = []Agent{claudeCode, cursor, opencode, copilot, codebuddy} + +// Lookup returns the agent with the given name. +func Lookup(name string) (Agent, bool) { + for _, a := range Agents { + if a.Name == name { + return a, true + } + } + return Agent{}, false +} + +// Names returns all agent names in registry order. +func Names() []string { + out := make([]string, len(Agents)) + for i, a := range Agents { + out[i] = a.Name + } + return out +} + +// Install materializes the agent's steps. Returns the paths written (or that +// would be written under DryRun), in order. +func (a Agent) Install(opts InstallOpts) ([]string, error) { + if a.steps == nil { + return nil, fmt.Errorf("agent %q has no install steps", a.Name) + } + steps, err := a.steps(opts) + if err != nil { + return nil, err + } + var written []string + for _, s := range steps { + w, err := execStep(s, opts) + if err != nil { + return written, err + } + if w != "" { + written = append(written, w) + } + } + return written, nil +} + +func execStep(s step, opts InstallOpts) (string, error) { + if opts.DryRun { + verb := "write" + if s.action == actionMergeJSON { + verb = "merge" + } + return fmt.Sprintf("%s %s", verb, s.path), nil + } + switch s.action { + case actionWriteFile: + if _, err := os.Stat(s.path); err == nil && !opts.Force { + return "", nil // skip existing unless --force + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(s.path, s.content, 0o644); err != nil { + return "", err + } + return s.path, nil + case actionCopyScript: + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(s.path, s.content, 0o755); err != nil { + return "", err + } + return s.path, nil + case actionMergeJSON: + return mergeJSONFile(s.path, s.fragment) + } + return "", fmt.Errorf("unknown action %d", s.action) +} + +// mergeJSONFile reads path (if present), deep-merges fragment into it (or +// starts from fragment if absent), backs up the original to .bak, and writes +// the result. Returns the path written. +func mergeJSONFile(path string, fragment map[string]any) (string, error) { + var existing map[string]any + if data, err := os.ReadFile(path); err == nil { + if err := json.Unmarshal(data, &existing); err != nil { + return "", fmt.Errorf("parse %s: %w", path, err) + } + if err := os.WriteFile(path+".bak", data, 0o644); err != nil { + return "", err + } + } + dst := existing + if dst == nil { + dst = map[string]any{} + } + deepMerge(dst, fragment) + out, err := json.MarshalIndent(dst, "", " ") + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { + return "", err + } + return path, nil +} + +// deepMerge merges src into dst (mutating dst). Maps recurse; for slices, dst +// keeps its elements and appends src elements not already present (dedup by +// JSON serialization). Scalars in src overwrite dst. +func deepMerge(dst, src map[string]any) { + for k, sv := range src { + dv, ok := dst[k] + if !ok { + dst[k] = sv + continue + } + if sm, sok := sv.(map[string]any); sok { + if dm, dok := dv.(map[string]any); dok { + deepMerge(dm, sm) + continue + } + } + if ss, sok := sv.([]any); sok { + if ds, dok := dv.([]any); dok { + dst[k] = dedupAppend(ds, ss) + continue + } + } + dst[k] = sv + } +} + +// dedupAppend appends src items to dst, skipping any whose JSON serialization +// already appears in dst. Order: dst preserved, new src items appended. +func dedupAppend(dst, src []any) []any { + seen := make(map[string]bool, len(dst)+len(src)) + for _, v := range dst { + seen[canon(v)] = true + } + for _, v := range src { + c := canon(v) + if !seen[c] { + seen[c] = true + dst = append(dst, v) + } + } + return dst +} + +// canon returns a stable JSON string for dedup comparison. +func canon(v any) string { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) +} + +// --- agent definitions --- + +var claudeCode = Agent{ + Name: "claude", + Desc: "Claude Code (PreToolUse hook -> ask)", + Cap: CapEnforce, + steps: func(o InstallOpts) ([]step, error) { + var base, hookCmd string + if o.Scope == ScopeGlobal { + base, hookCmd = o.Home, `python3 "$HOME/.claude/hooks/mysql-write-guard.py"` + } else { + base, hookCmd = o.ProjectDir, `python3 "${CLAUDE_PROJECT_DIR:-$PWD}/.claude/hooks/mysql-write-guard.py"` + } + frag := preToolUseFragment("Bash", hookCmd) + return []step{ + {path: filepath.Join(base, ".claude", "hooks", "mysql-write-guard.py"), action: actionCopyScript, content: hookScript}, + {path: filepath.Join(base, ".claude", "settings.json"), action: actionMergeJSON, fragment: frag}, + }, nil + }, +} + +var cursor = Agent{ + Name: "cursor", + Desc: "Cursor (.cursor/rules, guide only)", + Cap: CapGuide, + steps: func(o InstallOpts) ([]step, error) { + if o.Scope == ScopeGlobal { + return nil, errors.New("cursor: global scope not supported (Cursor user rules live in IDE settings); use --project") + } + return []step{ + {path: filepath.Join(o.ProjectDir, ".cursor", "rules", "mysql-cli-write-guard.mdc"), + action: actionWriteFile, content: []byte(cursorRule)}, + }, nil + }, +} + +var opencode = Agent{ + Name: "opencode", + Desc: "opencode (permission.bash glob -> ask)", + Cap: CapEnforce, + steps: func(o InstallOpts) ([]step, error) { + var path string + if o.Scope == ScopeGlobal { + path = filepath.Join(o.Home, ".config", "opencode", "opencode.json") + } else { + path = filepath.Join(o.ProjectDir, "opencode.json") + } + frag := map[string]any{ + "permission": map[string]any{ + "bash": map[string]any{ + "mysql-cli *": "allow", + "mysql-cli *--write*": "ask", + "mysql-cli *--ddl*": "ask", + "mysql-cli *--yes*": "ask", + }, + }, + } + return []step{{path: path, action: actionMergeJSON, fragment: frag}}, nil + }, +} + +var copilot = Agent{ + Name: "copilot", + Desc: "GitHub Copilot (autoApprove regex -> false)", + Cap: CapEnforce, + steps: func(o InstallOpts) ([]step, error) { + var steps []step + if o.Scope == ScopeProject { + steps = append(steps, step{ + path: filepath.Join(o.ProjectDir, ".github", "copilot-instructions.md"), + action: actionWriteFile, content: []byte(copilotInstructions), + }) + } + var settingsPath string + if o.Scope == ScopeGlobal { + p, err := vscodeUserSettings(o.Home) + if err != nil { + return nil, err + } + settingsPath = p + } else { + settingsPath = filepath.Join(o.ProjectDir, ".vscode", "settings.json") + } + frag := map[string]any{ + "chat.tools.terminal.autoApprove": map[string]any{ + "/--(write|ddl|yes)(\\b|=)/": false, + }, + } + steps = append(steps, step{path: settingsPath, action: actionMergeJSON, fragment: frag}) + return steps, nil + }, +} + +var codebuddy = Agent{ + Name: "codebuddy", + Desc: "CodeBuddy (PreToolUse hook -> ask)", + Cap: CapEnforce, + steps: func(o InstallOpts) ([]step, error) { + var base, hookCmd string + if o.Scope == ScopeGlobal { + base, hookCmd = o.Home, `python3 "$HOME/.codebuddy/hooks/mysql-write-guard.py"` + } else { + base, hookCmd = o.ProjectDir, `python3 "${CLAUDE_PROJECT_DIR:-$PWD}/.codebuddy/hooks/mysql-write-guard.py"` + } + frag := preToolUseFragment("Bash", hookCmd) + return []step{ + {path: filepath.Join(base, ".codebuddy", "hooks", "mysql-write-guard.py"), action: actionCopyScript, content: hookScript}, + {path: filepath.Join(base, ".codebuddy", "settings.json"), action: actionMergeJSON, fragment: frag}, + }, nil + }, +} + +// preToolUseFragment builds a hooks.PreToolUse fragment for matcher/bash-hook. +func preToolUseFragment(matcher, hookCmd string) map[string]any { + return map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []any{ + map[string]any{ + "matcher": matcher, + "hooks": []any{ + map[string]any{"type": "command", "command": hookCmd}, + }, + }, + }, + }, + } +} + +// vscodeUserSettings returns the VS Code User settings.json path for the OS. +func vscodeUserSettings(home string) (string, error) { + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Code", "User", "settings.json"), nil + case "windows": + return filepath.Join(home, "AppData", "Roaming", "Code", "User", "settings.json"), nil + default: // linux & friends + return filepath.Join(home, ".config", "Code", "User", "settings.json"), nil + } +} diff --git a/internal/agentsetup/agentsetup_test.go b/internal/agentsetup/agentsetup_test.go new file mode 100644 index 0000000..c71a3fb --- /dev/null +++ b/internal/agentsetup/agentsetup_test.go @@ -0,0 +1,164 @@ +package agentsetup + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeepMerge(t *testing.T) { + dst := map[string]any{ + "a": float64(1), + "b": map[string]any{"x": float64(1)}, + "c": []any{float64(1), float64(2)}, + "s": "old", + } + src := map[string]any{ + "b": map[string]any{"y": float64(2)}, + "c": []any{float64(2), float64(3)}, + "s": "new", + "d": float64(4), + } + deepMerge(dst, src) + assert.Equal(t, float64(1), dst["a"], "existing scalar kept") + assert.Equal(t, map[string]any{"x": float64(1), "y": float64(2)}, dst["b"], "maps recurse") + assert.Equal(t, []any{float64(1), float64(2), float64(3)}, dst["c"], "slice dedup + append") + assert.Equal(t, "new", dst["s"], "src scalar overwrites") + assert.Equal(t, float64(4), dst["d"], "new key added") +} + +func TestMergeJSONFile_New(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "opencode.json") + frag := map[string]any{"permission": map[string]any{"bash": map[string]any{"mysql-cli *--write*": "ask"}}} + written, err := mergeJSONFile(path, frag) + require.NoError(t, err) + assert.Equal(t, path, written) + data, err := os.ReadFile(path) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(data, &got)) + bash, _ := got["permission"].(map[string]any)["bash"].(map[string]any) + assert.Equal(t, "ask", bash["mysql-cli *--write*"]) +} + +func TestMergeJSONFile_ExistingPreToolUseDedup(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + existing := `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"rtk hook claude"}]}]},"other":"keep"}` + require.NoError(t, os.WriteFile(path, []byte(existing), 0o644)) + frag := preToolUseFragment("Bash", `python3 "$HOME/.claude/hooks/mysql-write-guard.py"`) + _, err := mergeJSONFile(path, frag) + require.NoError(t, err) + + // backup written + _, err = os.Stat(path + ".bak") + assert.NoError(t, err, ".bak backup created") + + data, err := os.ReadFile(path) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, "keep", got["other"], "unrelated existing key kept") + pre, _ := got["hooks"].(map[string]any)["PreToolUse"].([]any) + assert.Len(t, pre, 2, "rtk + our hook") + + // idempotent: second install must not duplicate our hook + _, err = mergeJSONFile(path, frag) + require.NoError(t, err) + data, _ = os.ReadFile(path) + json.Unmarshal(data, &got) + pre, _ = got["hooks"].(map[string]any)["PreToolUse"].([]any) + assert.Len(t, pre, 2, "second install does not duplicate our hook") +} + +func TestInstall_ClaudeProject(t *testing.T) { + dir := t.TempDir() + written, err := claudeCode.Install(InstallOpts{Scope: ScopeProject, ProjectDir: dir}) + require.NoError(t, err) + assert.Len(t, written, 2) + hookPath := filepath.Join(dir, ".claude", "hooks", "mysql-write-guard.py") + assert.Contains(t, written, hookPath) + info, err := os.Stat(hookPath) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&0o100, "hook script is executable") + data, err := os.ReadFile(filepath.Join(dir, ".claude", "settings.json")) + require.NoError(t, err) + assert.Contains(t, string(data), "mysql-write-guard.py") + assert.Contains(t, string(data), "CLAUDE_PROJECT_DIR") +} + +func TestInstall_CursorGlobalUnsupported(t *testing.T) { + _, err := cursor.Install(InstallOpts{Scope: ScopeGlobal, Home: t.TempDir()}) + require.Error(t, err) + assert.Contains(t, err.Error(), "global scope not supported") +} + +func TestInstall_CursorProjectWritesRule(t *testing.T) { + dir := t.TempDir() + written, err := cursor.Install(InstallOpts{Scope: ScopeProject, ProjectDir: dir}) + require.NoError(t, err) + require.Len(t, written, 1) + data, err := os.ReadFile(filepath.Join(dir, ".cursor", "rules", "mysql-cli-write-guard.mdc")) + require.NoError(t, err) + assert.Contains(t, string(data), "alwaysApply: true") +} + +func TestInstall_DryRunWritesNothing(t *testing.T) { + dir := t.TempDir() + written, err := opencode.Install(InstallOpts{Scope: ScopeProject, ProjectDir: dir, DryRun: true}) + require.NoError(t, err) + require.Len(t, written, 1) + assert.True(t, strings.HasPrefix(written[0], "merge")) + _, err = os.Stat(filepath.Join(dir, "opencode.json")) + assert.True(t, os.IsNotExist(err), "dry-run must not write") +} + +func TestInstall_OpencodeProjectMerge(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "opencode.json") + require.NoError(t, os.WriteFile(path, []byte(`{"$schema":"https://opencode.ai/config.json","permission":{"bash":{"ls":"allow"}}}`), 0o644)) + _, err := opencode.Install(InstallOpts{Scope: ScopeProject, ProjectDir: dir}) + require.NoError(t, err) + data, err := os.ReadFile(path) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, "https://opencode.ai/config.json", got["$schema"], "existing top-level kept") + bash, _ := got["permission"].(map[string]any)["bash"].(map[string]any) + assert.Equal(t, "allow", bash["ls"], "existing bash rule kept") + assert.Equal(t, "ask", bash["mysql-cli *--write*"], "our rule merged") +} + +func TestInstall_CopilotProjectFlatKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".vscode", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(`{"editor.tabSize":2}`), 0o644)) + _, err := copilot.Install(InstallOpts{Scope: ScopeProject, ProjectDir: dir}) + require.NoError(t, err) + data, err := os.ReadFile(path) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, float64(2), got["editor.tabSize"], "existing setting kept") + aa, ok := got["chat.tools.terminal.autoApprove"].(map[string]any) + require.True(t, ok, "flat VS Code key merged as one key") + assert.Equal(t, false, aa["/--(write|ddl|yes)(\\b|=)/"]) + _, err = os.Stat(filepath.Join(dir, ".github", "copilot-instructions.md")) + assert.NoError(t, err, "instructions written at project scope") +} + +func TestLookupAndNames(t *testing.T) { + a, ok := Lookup("claude") + require.True(t, ok) + assert.Equal(t, CapEnforce, a.Cap) + _, ok = Lookup("nope") + assert.False(t, ok) + assert.Len(t, Names(), 5) +} diff --git a/internal/agentsetup/templates/codebuddy.md b/internal/agentsetup/templates/codebuddy.md new file mode 100644 index 0000000..b1abd3e --- /dev/null +++ b/internal/agentsetup/templates/codebuddy.md @@ -0,0 +1,5 @@ +# mysql-cli 写操作守卫 + +`mysql-cli` 默认只读。命令含 `--write`/`--ddl`/`--yes` 任一即为写/破坏性操作。执行前必须向用户展示完整命令与影响范围并请求人工确认;`--yes` 是标记破坏性操作而非豁免,不得用它绕过确认。完整安全模型见 `skills/mysql-shared/SKILL.md` 的 Security Model 段。 + +> 强制拦截由 `.codebuddy/settings.json` 的 PreToolUse hook 实现(命中写 flag 即弹窗),本文件为补充引导。 diff --git a/internal/agentsetup/templates/copilot-instructions.md b/internal/agentsetup/templates/copilot-instructions.md new file mode 100644 index 0000000..49d11e5 --- /dev/null +++ b/internal/agentsetup/templates/copilot-instructions.md @@ -0,0 +1,5 @@ +# mysql-cli 写操作守卫 + +`mysql-cli` 默认只读。命令含 `--write`/`--ddl`/`--yes` 任一即为写/破坏性操作。执行前必须向用户展示完整命令与影响范围并请求人工确认;`--yes` 是标记破坏性操作而非豁免,不得用它绕过确认。完整安全模型见 `skills/mysql-shared/SKILL.md` 的 Security Model 段。 + +> 强制拦截由 `.vscode/settings.json` 的 `chat.tools.terminal.autoApprove` 实现(命中写 flag 的正则即强制人工确认),本文件为补充引导。 diff --git a/internal/agentsetup/templates/cursor-rule.mdc b/internal/agentsetup/templates/cursor-rule.mdc new file mode 100644 index 0000000..5b9ba87 --- /dev/null +++ b/internal/agentsetup/templates/cursor-rule.mdc @@ -0,0 +1,18 @@ +--- +description: Guard mysql-cli write/ddl/destructive operations; request human approval before running them +globs: +alwaysApply: true +--- +# mysql-cli 写操作守卫 + +`mysql-cli` 默认只读。若命令行包含 `--write`、`--ddl`、`--yes` 中的任意一个,即为写/破坏性操作。 + +执行前**必须**: + +1. 停下来,向用户展示完整命令与影响范围; +2. 明确请求人工确认; +3. 未获明确批准前不得执行。 + +`--yes` 是“标记破坏性操作”,**不是**豁免确认——不得用 `--yes` 绕过本规则。 + +完整安全模型见 `skills/mysql-shared/SKILL.md` 的 Security Model 段。 diff --git a/internal/agentsetup/templates/mysql-write-guard.py b/internal/agentsetup/templates/mysql-write-guard.py new file mode 100644 index 0000000..8b364bd --- /dev/null +++ b/internal/agentsetup/templates/mysql-write-guard.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""PreToolUse hook: force a human confirmation prompt for mysql-cli writes. + +Shared by agents whose hook system is compatible with Claude Code's PreToolUse +contract (Claude Code, CodeBuddy). The hook inspects the Bash command from +stdin; if it is a mysql-cli invocation carrying any write gate flag +(--write / --ddl / --yes), it returns permissionDecision="ask" so the agent +surfaces a permission prompt -- pulling a human into the loop instead of +letting the AI self-confirm via --yes. + +Read-only mysql-cli calls (no write flag) pass through silently. + +Detection notes: +- shlex token matching: a flag-looking string inside a quoted SQL literal (e.g. + "SELECT '--write' ...") stays one quoted token and is NOT mistaken for the flag. +- A regex fallback anchors flags as standalone tokens; the back boundary also + allows quote/shell-separator close so `bash -c "mysql-cli ... --write"` is caught. +- rtk / sudo / env / nohup / command prefixes are skipped when locating the cmd. +- Fail-open on parse errors: a broken hook must not block all Bash usage. +""" +import json +import os +import re +import shlex +import sys + +WRITE_FLAGS = {"--write", "--ddl", "--yes"} +PREFIXES = {"rtk", "sudo", "env", "nohup", "command"} +RTK_SUB = {"proxy"} # `rtk proxy ` form + + +def _command_word(tokens): + """Return the first real command token, skipping rtk/sudo/env wrappers.""" + i = 0 + while i < len(tokens): + base = os.path.basename(tokens[i]) + if base in PREFIXES: + i += 1 + if base == "rtk" and i < len(tokens) and tokens[i] in RTK_SUB: + i += 1 + continue + return base + return "" + + +def _is_mysql_cli_write(command): + """True if command runs mysql-cli with a write gate flag.""" + try: + tokens = shlex.split(command) + except ValueError: + tokens = None + + if tokens: + cmd = _command_word(tokens) + is_mysql = cmd == "mysql-cli" or cmd.endswith("/mysql-cli") + if is_mysql and WRITE_FLAGS.intersection(tokens): + return True + + # Fallback: strict front boundary (start/whitespace) so a flag inside a + # quoted SQL literal is not matched; back boundary allows quote/separator + # close so wrapped calls like `bash -c "mysql-cli ... --write"` are caught. + if re.search(r"\bmysql-cli\b", command): + for flag in WRITE_FLAGS: + if re.search(r"(?:^|\s)" + re.escape(flag) + r"(?=[\s\"';|&]|$)", command): + return True + return False + + +def main(): + raw = sys.stdin.read() + if not raw.strip(): + return + try: + event = json.loads(raw) + except json.JSONDecodeError: + return + + if event.get("tool_name") != "Bash": + return + command = (event.get("tool_input") or {}).get("command", "") + if not command or not _is_mysql_cli_write(command): + return + + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "ask", + "permissionDecisionReason": ( + "mysql-cli write operation (--write/--ddl/--yes) requires human " + "confirmation. Approve only if you intend to modify the database." + ), + } + })) + + +if __name__ == "__main__": + try: + main() + except Exception as e: # never let a bug block all Bash + sys.stderr.write(f"mysql-write-guard: fail-open: {e}\n") + sys.exit(0) diff --git a/internal/cli/agent_cmd.go b/internal/cli/agent_cmd.go new file mode 100644 index 0000000..a944ccd --- /dev/null +++ b/internal/cli/agent_cmd.go @@ -0,0 +1,238 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/AllenMuu/mysql-cli/internal/agentsetup" + "github.com/chzyer/readline" + "github.com/spf13/cobra" +) + +// newAgentCmd wires the "agent" parent command. Today it has one subcommand, +// "init", which installs per-agent write-confirmation configs. +func newAgentCmd(g *Globals) *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Short: "Install per-agent write-confirmation configs for mysql-cli", + } + cmd.AddCommand(newAgentInitCmd(g)) + return cmd +} + +// newAgentInitCmd implements `agent init`: installs configs that force a human +// confirmation prompt before mysql-cli write operations. Interactive by default +// in a TTY (multi-select agents + scope); non-interactive via --agents and +// --project/--global. +func newAgentInitCmd(g *Globals) *cobra.Command { + c := &cobra.Command{ + Use: "init", + Short: "Install write-confirmation configs into the agents you use", + Long: `Install per-agent configs that force a human confirmation prompt before +mysql-cli write operations (--write/--ddl/--yes). Interactive by default in a +TTY; use --agents and --project/--global for non-interactive use. + +Supported agents: ` + strings.Join(agentsetup.Names(), ", ") + `.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAgentInit(cmd, g) + }, + } + c.Flags().String("agents", "", "comma-separated agent names (non-interactive)") + c.Flags().Bool("project", false, "write to the current project") + c.Flags().Bool("global", false, "write to the user-level config (~/.claude, ~/.config/opencode, ...)") + c.Flags().Bool("force", false, "overwrite single-file configs that already exist") + c.Flags().Bool("dry-run", false, "print actions without writing") + c.Flags().BoolP("json", "j", false, "emit JSON") + return c +} + +func runAgentInit(cmd *cobra.Command, g *Globals) error { + agentsFlag, _ := cmd.Flags().GetString("agents") + project, _ := cmd.Flags().GetBool("project") + global, _ := cmd.Flags().GetBool("global") + force, _ := cmd.Flags().GetBool("force") + dryRun, _ := cmd.Flags().GetBool("dry-run") + asJSON, _ := cmd.Flags().GetBool("json") + + if project && global { + return errors.New("specify only one of --project or --global") + } + + tty := stdinIsTerminal() + + // resolve agent names + var names []string + if agentsFlag != "" { + for _, n := range strings.Split(agentsFlag, ",") { + if n = strings.TrimSpace(n); n != "" { + names = append(names, n) + } + } + } else if tty { + names = promptAgents() + } else { + return errors.New("not a TTY; pass --agents= and --project/--global") + } + if len(names) == 0 { + return errors.New("no agents selected") + } + for _, n := range names { + if _, ok := agentsetup.Lookup(n); !ok { + return fmt.Errorf("unknown agent %q (available: %s)", n, strings.Join(agentsetup.Names(), ", ")) + } + } + + // resolve scope + var scope agentsetup.Scope + switch { + case project: + scope = agentsetup.ScopeProject + case global: + scope = agentsetup.ScopeGlobal + default: + if tty { + scope = promptScope() + } else { + return errors.New("specify --project or --global") + } + } + + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("cannot determine home: %w", err) + } + cwd, _ := os.Getwd() + opts := agentsetup.InstallOpts{ + Scope: scope, + Force: force, + DryRun: dryRun, + ProjectDir: cwd, + Home: home, + } + + type res struct { + Agent string `json:"agent"` + Written []string `json:"written,omitempty"` + Error string `json:"error,omitempty"` + } + var results []res + for _, n := range names { + a, _ := agentsetup.Lookup(n) + written, err := a.Install(opts) + r := res{Agent: n, Written: written} + if err != nil { + r.Error = err.Error() + } + results = append(results, r) + } + + w := cmd.OutOrStdout() + if asJSON { + payload := map[string]any{"success": true, "data": map[string]any{ + "scope": scope.String(), + "dry_run": dryRun, + "results": results, + }} + b, _ := json.MarshalIndent(payload, "", " ") + fmt.Fprintln(w, string(b)) + return nil + } + fmt.Fprintf(w, "scope: %s%s\n", scope, dryRunSuffix(dryRun)) + for _, r := range results { + if r.Error != "" { + fmt.Fprintf(w, " ❌ %s: %s\n", r.Agent, r.Error) + continue + } + fmt.Fprintf(w, " ✅ %s:\n", r.Agent) + for _, p := range r.Written { + fmt.Fprintf(w, " %s\n", p) + } + } + return nil +} + +func dryRunSuffix(dryRun bool) string { + if dryRun { + return " (dry-run)" + } + return "" +} + +// stdinIsTerminal reports whether stdin is a character device (a TTY). It is a +// variable so tests can force the non-interactive path. +var stdinIsTerminal = func() bool { + info, err := os.Stdin.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +// promptAgents runs an interactive multi-select menu and returns the chosen +// agent names. Returns nil on EOF. +func promptAgents() []string { + rl, err := readline.New("") + if err != nil { + return nil + } + defer rl.Close() + fmt.Println("Select agents to install (comma-separated numbers, e.g. 1,3,4):") + for i, a := range agentsetup.Agents { + fmt.Printf(" %d) %-10s %s [%s]\n", i+1, a.Name, a.Desc, a.Cap) + } + for { + rl.SetPrompt("agents> ") + line, err := rl.Readline() + if err != nil { + return nil // EOF + } + names := pickAgents(line) + if len(names) > 0 { + return names + } + fmt.Fprintln(os.Stderr, " no valid selection, try again") + } +} + +// promptScope runs an interactive scope picker. +func promptScope() agentsetup.Scope { + rl, err := readline.New("") + if err != nil { + return agentsetup.ScopeProject + } + defer rl.Close() + for { + rl.SetPrompt("scope [1=project, 2=global]> ") + line, err := rl.Readline() + if err != nil { + return agentsetup.ScopeProject // EOF default + } + switch strings.TrimSpace(line) { + case "1", "project": + return agentsetup.ScopeProject + case "2", "global": + return agentsetup.ScopeGlobal + } + fmt.Fprintln(os.Stderr, " enter 1 (project) or 2 (global)") + } +} + +// pickAgents parses "1,3,4" into agent names, ignoring out-of-range/invalid tokens. +func pickAgents(line string) []string { + var names []string + for _, part := range strings.Split(line, ",") { + n, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil { + continue + } + if n >= 1 && n <= len(agentsetup.Agents) { + names = append(names, agentsetup.Agents[n-1].Name) + } + } + return names +} diff --git a/internal/cli/agent_cmd_test.go b/internal/cli/agent_cmd_test.go new file mode 100644 index 0000000..bdd3c23 --- /dev/null +++ b/internal/cli/agent_cmd_test.go @@ -0,0 +1,103 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// chdir changes to dir for the test and restores cwd on cleanup. +func chdir(t *testing.T, dir string) { + t.Helper() + orig, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(orig) }) +} + +func newAgentInitCmdForTest(t *testing.T) (*cobra.Command, *bytes.Buffer) { + t.Helper() + buf := &bytes.Buffer{} + c := newAgentInitCmd(&Globals{}) + c.SetOut(buf) + c.SetErr(buf) + return c, buf +} + +func TestAgentInitDryRunProject(t *testing.T) { + dir := t.TempDir() + chdir(t, dir) + c, buf := newAgentInitCmdForTest(t) + c.SetArgs([]string{"--agents", "claude", "--project", "--dry-run"}) + require.NoError(t, c.Execute()) + assert.Contains(t, buf.String(), "claude") + // dry-run wrote nothing + _, err := os.Stat(filepath.Join(dir, ".claude", "settings.json")) + assert.True(t, os.IsNotExist(err)) +} + +func TestAgentInitWriteOpencode(t *testing.T) { + dir := t.TempDir() + chdir(t, dir) + c, _ := newAgentInitCmdForTest(t) + c.SetArgs([]string{"--agents", "opencode", "--project"}) + require.NoError(t, c.Execute()) + data, err := os.ReadFile(filepath.Join(dir, "opencode.json")) + require.NoError(t, err) + assert.Contains(t, string(data), "mysql-cli *--write*") +} + +func TestAgentInitUnknownAgent(t *testing.T) { + chdir(t, t.TempDir()) + c, _ := newAgentInitCmdForTest(t) + c.SetArgs([]string{"--agents", "nope", "--project"}) + err := c.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown agent") +} + +func TestAgentInitProjectGlobalMutex(t *testing.T) { + chdir(t, t.TempDir()) + c, _ := newAgentInitCmdForTest(t) + c.SetArgs([]string{"--agents", "claude", "--project", "--global"}) + err := c.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "only one of") +} + +func TestAgentInitJSON(t *testing.T) { + chdir(t, t.TempDir()) + c, buf := newAgentInitCmdForTest(t) + c.SetArgs([]string{"--agents", "opencode", "--project", "--dry-run", "--json"}) + require.NoError(t, c.Execute()) + assert.Contains(t, buf.String(), `"results"`) + assert.Contains(t, buf.String(), `"opencode"`) + assert.Contains(t, buf.String(), `"dry_run": true`) +} + +func TestAgentInitNotTTYNoFlags(t *testing.T) { + chdir(t, t.TempDir()) + orig := stdinIsTerminal + stdinIsTerminal = func() bool { return false } + t.Cleanup(func() { stdinIsTerminal = orig }) + c, _ := newAgentInitCmdForTest(t) + c.SetArgs([]string{}) + err := c.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "not a TTY") +} + +func TestAgentInitCursorGlobalErrorInResults(t *testing.T) { + chdir(t, t.TempDir()) + c, buf := newAgentInitCmdForTest(t) + c.SetArgs([]string{"--agents", "cursor", "--global"}) + // cursor global is unsupported, but reported in results (not as a cmd error) + require.NoError(t, c.Execute()) + assert.Contains(t, buf.String(), "cursor") + assert.Contains(t, buf.String(), "global scope not supported") +} diff --git a/internal/cli/help.go b/internal/cli/help.go index 6b8e3c5..70c286a 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -31,7 +31,7 @@ func applyHelpGrouping(root *cobra.Command) { c.GroupID = groupSQL case "schema", "sample", "tables", "databases", "read", "explore", "analyze": c.GroupID = groupSchema - case "config", "skill", "init", "version": + case "config", "skill", "init", "version", "agent": c.GroupID = groupManage } } @@ -52,4 +52,6 @@ Agent notes: DROP/TRUNCATE and WHERE-less UPDATE/DELETE need --yes. Skills: 'mysql-cli skill install' installs agent skills; 'mysql-cli skill check' verifies version sync. + Write guard: 'mysql-cli agent init' installs per-agent configs that prompt a + human before mysql-cli writes (--write/--ddl/--yes). ` diff --git a/internal/cli/root.go b/internal/cli/root.go index 5ac0986..0275c71 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -109,6 +109,7 @@ func newRootCmd(g *Globals) *cobra.Command { newExploreCmd(g), newAnalyzeCmd(g), newConfigCmd(g), + newAgentCmd(g), newVersionCmd(), ) // No subcommand -> interactive REPL (human debug; not the agent path). diff --git a/skills/mysql-query/SKILL.md b/skills/mysql-query/SKILL.md index 95d497c..bca4065 100644 --- a/skills/mysql-query/SKILL.md +++ b/skills/mysql-query/SKILL.md @@ -73,6 +73,11 @@ All commands share global flags (see `mysql-shared`): `-d/--datasource`, Writes are gated by safety flags (see `mysql-shared` Security Model). Destructive ops additionally need `--yes`. +> **Human confirmation required**: every write flag (`--write`/`--ddl`/`--yes`) +> triggers a Claude Code permission prompt. `--yes` marks the op as destructive +> -- it does **not** skip review. Add the flags the op needs; a human approves +> the actual execution. + | Intent | Command | | --- | --- | | DML (INSERT/UPDATE/DELETE) | `mysql-cli query "" --write` | @@ -82,7 +87,9 @@ Destructive ops additionally need `--yes`. > Safety flags at a glance: > `--write` unlocks DML · `--ddl` unlocks DDL (**requires** `--write`) · -> `--yes` confirms destructive ops. +> `--yes` marks a destructive op. **Every write flag (`--write`/`--ddl`/`--yes`) +> triggers a human confirmation prompt** -- `--yes` requests execution, it does +> not self-confirm. Never add `--yes` to bypass human review. --- diff --git a/skills/mysql-shared/SKILL.md b/skills/mysql-shared/SKILL.md index 8fcc945..7ec98fa 100644 --- a/skills/mysql-shared/SKILL.md +++ b/skills/mysql-shared/SKILL.md @@ -12,7 +12,7 @@ metadata: config_file: ~/.config/mysql-cli/config.toml default_output: json output_formats: json | table | csv | tsv | jsonl - safety_model: read-only by default; --write (DML), --write --ddl (DDL), --yes (destructive); SELECT default cap 1000 (--no-limit to disable) + safety_model: read-only by default; --write (DML), --write --ddl (DDL), --yes (destructive); every write flag triggers a human confirmation prompt (PreToolUse hook) -- --yes requests execution, it does not self-confirm; SELECT default cap 1000 (--no-limit to disable) license: MIT replaces: designcomputer/mysql_mcp_server --- @@ -204,10 +204,15 @@ flag never silently mutates data. > Safety flags at a glance: > `--write` unlocks DML · `--ddl` unlocks DDL (**requires** `--write`) · -> `--yes` confirms destructive ops. +> `--yes` marks a destructive op. **Every write flag triggers a human +> confirmation prompt** -- `--yes` requests execution, it does not self-confirm. Additional guarantees: +- **Human-in-the-loop on writes**: a `PreToolUse` hook (global + `~/.claude/settings.json`) intercepts any `mysql-cli` call carrying + `--write`/`--ddl`/`--yes` and turns it into a Claude Code permission prompt. + The AI cannot self-confirm destructive work -- approval is the human's call. - **Identifier allowlist**: table/db names must match `^[a-zA-Z0-9_$]+$`; qualified `db.table` is allowed. Prevents injection in schema-exploration SQL. - **Multi-statement rejection**: `query` accepts a single statement (one @@ -231,7 +236,8 @@ Additional guarantees: - **Validate WHERE on reads first**: before an `UPDATE`/`DELETE`, run the same `WHERE` as a `SELECT COUNT(*)` to confirm scope. - **Match flags to intent**: DML -> `--write`; DDL -> `--write --ddl`; - destructive -> add `--yes`. Don't add `--yes` speculatively. + destructive -> add `--yes`. Don't add `--yes` speculatively. Any write flag + triggers a human confirmation prompt; `--yes` is a request, not a waiver. - **Reuse the connection model**: each invocation opens and closes its own pool (and SSH tunnel). Don't try to hold connections across calls; just issue separate commands.