From b4d916d6516ae8a12de911ac77d3baf5cafff928 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 12:21:30 +0800 Subject: [PATCH 01/29] docs(spec): add default-limit + envelope-slim design spec Brainstormed design for mysql-cli default safe LIMIT (cap=1000, cap+1 probe, meta.truncated) + JSON envelope slimming (drop rows_affected on SELECT, add --format jsonl). Driven by scripts/token-shootout.py findings: bare SELECT returns 6.8M (MCP) / 9.1M (CLI) tokens, both overflow any agent session. --- ...26-07-24-mysql-cli-default-limit-design.md | 141 +++++++ scripts/token-shootout.md | 122 ++++++ scripts/token-shootout.py | 382 ++++++++++++++++++ 3 files changed, 645 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-24-mysql-cli-default-limit-design.md create mode 100644 scripts/token-shootout.md create mode 100644 scripts/token-shootout.py diff --git a/docs/superpowers/specs/2026-07-24-mysql-cli-default-limit-design.md b/docs/superpowers/specs/2026-07-24-mysql-cli-default-limit-design.md new file mode 100644 index 0000000..c4523ad --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-mysql-cli-default-limit-design.md @@ -0,0 +1,141 @@ +# mysql-cli 默认安全 LIMIT + 信封瘦身优化 + +- 日期: 2026-07-24 +- 状态: 设计已批准,待实现计划 +- 关联实测: `scripts/token-shootout.py`(mysql-cli vs mysql-mcp A/B) + +## 背景 + +`scripts/token-shootout.py` 的 `--no-limit` 实测(`sd_cx_order`,44,516 行)揭示: + +| 方法 | ≈tokens | 说明 | +|---|---:|---| +| MCP `execute_sql`(无 LIMIT) | 6,809,371 | mysql-mcp-server **无自动行数上限** | +| CLI `query`(无 `--limit`) | 9,085,333 | `applyLimit` 在 `--limit` 未设时是 no-op(`query.go:95`),同样全表扫 | +| CLI `query --limit 20` | 3,977 | 显式 flag 才截断 | + +680~908 万 token = 34~45 个 200K context 窗口,一次忘加 LIMIT 的全表扫会当场撑爆任何 agent 会话。**两条路径裸跑都爆**,且 CLI 裸跑比 MCP 更费(+33%,JSON 信封更胖)。当前 CLI 的 `--limit` 是显式 opt-in,不是默认保护。 + +格式实测另显示:CLI `--format json` 比 MCP 胖 33%(36MB vs 27MB),只有 `csv` 能追平;`table` 格式是 token 杀手(+493%)。 + +## 目标 + +1. **默认安全 LIMIT**:SELECT 不带 LIMIT 时自动加 cap,消灭裸跑爆量灾难。对标 MCP 无保护的弱点,契合 README 的 "Safe by default" 定位--从"和 MCP 一样会爆"变成"默认就安全"。 +2. **顺带信封瘦身**:缩小 CLI json 与 MCP 的 token 差距。 + +## 非目标 + +- 不改 DML/DDL 路径(cap 只作用于 read) +- 不引入 daemon / 连接池(短进程单二进制哲学不变;CLI 已比 MCP 快) +- 不保护 `SHOW`/`DESCRIBE`(语法不支持子查询 wrap,且结果集通常小;实测 `SHOW TABLES` 仅 15KB) +- 不优化 `table` 格式的 token 开销(它面向人类,非 agent 路径) + +## 决策汇总(已与用户确认批准) + +| 决策点 | 选择 | +|---|---| +| 北极星 | LIMIT 优先 + 顺带信封瘦身,一个 spec 覆盖 | +| 兼容策略 | 改默认开(breaking);`--no-limit` 关;`--limit` 显式 flag 保留;已带 LIMIT 的 SQL 不动 | +| 截断知情 | cap+1 探测 + `meta.truncated` 标记(零额外查询) | +| 信封瘦身 | 方案 A:SELECT 省 `rows_affected` + 新增 `--format jsonl` | +| cap 默认值 | 1000(`config.toml` / env 可配) | +| jsonl 截断传递 | stderr warning | + +## §1 默认安全 LIMIT 机制 + +**改动位置**:`internal/query/query.go`(`applyLimit` 改造) + `internal/config`(加 `DefaultLimit`) + `internal/cli`(新增 `--no-limit` flag) + +**cap 来源与优先级**: +`--limit N`(显式,精确 N 行,不探测截断) > `--no-limit`(完全禁用 cap) > config `default_limit` > env `MYSQL_CLI_DEFAULT_LIMIT` > 内置默认 **1000** + +**数据流**(query.Execute 对 read 类 SQL): + +1. `--no-limit` 设 -> 原样执行,不 cap(agent 自担风险,等价今天的行为) +2. `--limit N` 显式 -> wrap `LIMIT N`,精确返回 N 行,**不标 truncated** +3. 默认 cap 生效 -> 若 SQL 无 LIMIT(`hasLimit=false`)且是 SELECT/WITH -> wrap `SELECT * FROM () AS _q LIMIT cap+1`;执行后若返回 cap+1 行 -> `truncated=true`,丢弃多余行只返 cap 行;否则 `truncated=false` +4. `truncated` + 实际 `limit` 传给 format 层写入 `meta` + +**关键边界**: + +- **已带 LIMIT 的 SQL 不动**(`hasLimit=true`,不二次 wrap)--保留原行为 +- **只 wrap SELECT/WITH**:`selectRe` 只匹配 SELECT/WITH;`SHOW`/`DESCRIBE`/`EXPLAIN` 语法不支持子查询 wrap,不 cap +- **`--limit` 显式时不标 truncated**:用户明确要 N 行,截断语义无意义 +- **非 read(DML/DDL)不涉及** cap + +## §2 信封 / jsonl / 配置 / flag / 退出码 / skill / 测试 + +### §2.1 信封与 jsonl(format 层) + +- SELECT 走新 `format.ReadJSON(r, truncated, limit)`:省 `rows_affected`(对 SELECT 恒 0),`meta:{truncated, limit}`;DML/DDL 仍用现有 `SuccessJSON`(保留 `rows_affected`) +- 新增 `--format jsonl`:每行一个 JSON 对象 `{col:val,...}`,NULL 渲染为原生 `null` +- jsonl 的 truncated 传递:**stderr 输出一行 `# truncated:true limit:1000`**(jsonl 是纯行流,stdout 不混 meta;agent 可选读 stderr)。不采用末行 `{"_truncated":true}` 方案,因为会污染行流 + +SELECT 默认 json 输出示例: + +```json +{"success":true,"data":{"columns":["id","name"],"rows":[[1,"a"]]},"meta":{"truncated":false,"limit":1000}} +``` + +jsonl 示例: + +``` +{"id":1,"name":"a"} +{"id":2,"name":"b"} +``` + +### §2.2 配置(config 层) + +- config.toml 顶层 `default_limit = 1000`(不新建子表,KISS) +- env `MYSQL_CLI_DEFAULT_LIMIT` +- Resolve 优先级:`--limit` > `--no-limit` > config > env > 默认 1000 + +### §2.3 flag / 退出码(cli 层) + +- 新增全局 `--no-limit`(bool);`--limit` 语义不变 +- **不新增退出码**:truncated 在 meta,success 仍 exit 0(截断不是错误,是安全保护) +- breaking:CHANGELOG 明示"SELECT 默认 cap 1000" + +### §2.4 skill 更新(skills/) + +- `mysql-shared/SKILL.md`:加默认 cap 行为 + `truncated` 含义 + `--no-limit` + `jsonl` +- `mysql-query/SKILL.md`:教 agent 见 `truncated:true` -> 主动 `--no-limit` 或 `COUNT(*)` 看全量;省 token 选 `jsonl` +- 两个 skill `version` frontmatter bump(skillscheck 对比) + +### §2.5 测试 + +- **单测**(sqlmock,无需 DB): + - `applyLimit` 默认 cap(无 `--limit` 无 `--no-limit`)-> wrap `LIMIT cap+1` + - `hasLimit=true` 不 wrap + - `--no-limit` 不 wrap + - `--limit N` 显式 wrap `LIMIT N`(不加 +1,不标 truncated) + - cap+1 探测两分支:返回 cap+1 行 -> `truncated=true` + 丢多余行;返回 ≤cap -> `truncated=false` + - `SHOW`/`DESCRIBE` 不 wrap(`selectRe` 不匹配) + - `ReadJSON`:省 `rows_affected` + `meta.truncated/limit` + - `SuccessJSON`(write):保留 `rows_affected` + - jsonl 输出格式 + NULL 渲染为 `null` + - config `default_limit` 优先级(flag > no-limit > config > env > 默认) +- **集成**(testcontainers-go,`mysql:8`):插 >1000 行,验证截断 + `truncated` 标记 + `--no-limit` 全返回 +- **shootout 复测**:加 cap 后 `--no-limit` 档的"默认 cap"分支应被截到 1000 行(≈token 从 908 万降到 ~20 万),作回归证据 + +## 架构改动位置一览 + +| 包/文件 | 改动 | +|---|---| +| `internal/query/query.go` | `applyLimit` 改造:默认 cap + cap+1 探测;`Execute` 扫描循环计数 + truncated 判定 | +| `internal/config` | 加 `DefaultLimit` 字段;`Resolve` 纳入 `default_limit` / `MYSQL_CLI_DEFAULT_LIMIT` | +| `internal/cli` | 新增全局 `--no-limit` flag;query 子命令把 cap/no-limit 透传 query 层 | +| `internal/format/format.go` | 新增 `ReadJSON(r, truncated, limit)`;新增 jsonl 分支;`SuccessJSON` 不变 | +| `skills/mysql-shared/SKILL.md` | 默认 cap + truncated + `--no-limit` + jsonl 说明;version bump | +| `skills/mysql-query/SKILL.md` | agent 适配指引;version bump | +| `CHANGELOG.md` | breaking:SELECT 默认 cap 1000 | + +## Breaking Change 与迁移 + +- **SELECT 默认 cap 1000**:现有依赖全表返回的 agent 需显式加 `--no-limit`(或调大 `default_limit` / `--limit N`)。这类用法本就危险,迁移成本低。 +- **SELECT 的 json 信封省 `rows_affected`**:该字段对 SELECT 恒为 0,现有解析它的 agent 拿到的是缺失而非错误,影响小。 +- **CHANGELOG 明示**;skill 更新教 agent 识别 `truncated` 并主动看全量。 +- 建议在下一个版本号体现 breaking(按 semver)。 + +## 开放问题 + +无。所有关键决策已在 brainstorming 阶段与用户确认(jsonl 截断走 stderr、cap=1000、改默认开等)。 diff --git a/scripts/token-shootout.md b/scripts/token-shootout.md new file mode 100644 index 0000000..7043fc9 --- /dev/null +++ b/scripts/token-shootout.md @@ -0,0 +1,122 @@ +# token-shootout + +A/B comparison between **mysql-mcp** (the MCP server Claude Code calls natively) +and **mysql-cli** (the shell-agent path) for the same query tasks against the +same database. Measures **latency**, **response bytes**, and an **estimated +token count**. + +## Why + +`mysql-cli` is a drop-in replacement for `mysql-mcp-server`. This script gives +you hard evidence for the tradeoffs: which path is faster, which returns less +data to the model, and how `mysql-cli`'s output formats change the bill. + +## Prerequisites + +- `mysql-cli` on disk (auto-detected: `PATH` → `~/go/bin` → `~/.local/bin` → + repo root). Override with `--cli /path/to/mysql-cli`. +- `uvx` on `PATH` (used to launch the MCP server). +- A populated `.mcp.json` at the repo root (the script reads `MYSQL_*` creds + from it and injects them into **both** paths so they hit the same DB). + +## Run + +```bash +# default (uses information_schema.tables so it runs anywhere) +python3 scripts/token-shootout.py + +# real table, write markdown to file +python3 scripts/token-shootout.py --table users --out result.md + +# debug MCP tool/param discovery +python3 scripts/token-shootout.py -v + +# add a heavy full-table-scan task (bare SELECT vs --limit guard). +# pulls the WHOLE table -- run on a table you know, mind the DB load. +python3 scripts/token-shootout.py --table sd_cx_order --no-limit +``` + +## How to read the output + +Each task produces a table: `method | latency (ms) | bytes | ≈tokens | status`. +A final **Summary** table compares every method's ≈tokens against the MCP +baseline for that task (`baseline` / `-N%` = smaller / `+N%` = larger). + +## Fairness notes + +- **Same DB, same creds**: `MYSQL_*` from `.mcp.json` is injected into both the + MCP server process and the `mysql-cli` subprocess. +- **MCP is warmed up** before measuring (a throwaway `SELECT 1`), so its numbers + reflect the hot path Claude Code actually sees (resident process, warm + connection). Without this, the first call is dominated by `uvx` cold start + (~700 ms) and is not representative. +- **CLI is NOT warmed up** on purpose. It is a short-lived process by design; + its per-call fork + connect cost is the real shell-agent cost model. +- **MCP param names are auto-probed** via `tools/list`, so the script adapts to + different `mysql-mcp-server` versions (`execute_sql`→`query`, + `get_schema_info`→`table_name`, …). + +## Token estimate caveat + +`≈tokens = response bytes ÷ 4`. This is a rough proxy: + +- It is accurate-ish for ASCII JSON (the common case). +- It **under-counts** for CJK / emoji content (UTF-8 multibyte chars cost more + tokens per byte). +- Real token counts live only in the **Claude Code transcript** (what the model + actually consumed, including tool-call overhead and the surrounding envelope), + which this script cannot see. + +**Treat `bytes` as the honest comparison axis; treat `≈tokens` as an +order-of-magnitude hint.** For authoritative token numbers, run the same query +both ways from a real Claude Code session and diff the transcript costs. + +## What the default run typically shows + +(On `information_schema.tables` against a real DB; your numbers will vary.) + +- **Latency**: `mysql-cli` (compiled Go binary) is usually faster per call than + the warm MCP server (Python). The MCP server's resident-process advantage + does not always beat Go's lower per-call overhead at agent query cadence. +- **Bytes/tokens**: the MCP server's default output is already compact. `mysql-cli` + `--format json` is slightly larger (full envelope + field names); `--format csv` + matches MCP closely; `--format table` is the most expensive (avoid it when + token cost matters — it is for humans, not agents). + +So the CLI's win over MCP is **speed + portability (any shell agent) + +multi-datasource + exit-code contract + debuggability** — not raw token +savings, unless you pick `csv`. + +## The `--no-limit` task: cost of an unguarded full-table scan + +`--no-limit` appends a task that runs a bare `SELECT * FROM ` (no LIMIT) +three ways, to expose what each path does when the agent forgets to cap rows: + +| method | behavior | +|---|---| +| MCP `execute_sql` (no LIMIT) | mysql-mcp-server does **not** auto-cap; full table returned | +| CLI `query` (no `--limit`) | `applyLimit` is a no-op when `--limit` is unset; full table returned | +| CLI `query --limit 20` | `applyLimit` wraps as `SELECT * FROM () AS _q LIMIT 20`; capped | + +Measured on `sd_cx_order` (44,516 rows): + +| method | ≈tokens | vs MCP bare | +|---|---:|---:| +| MCP bare | 6,809,371 | baseline | +| CLI bare | 9,085,333 | +33% | +| CLI `--limit 20` | 3,977 | -100% (saves 99.94%) | + +Takeaways: + +- **Both paths blow up bare.** MCP returns ~6.8M tokens, CLI ~9.1M - roughly + **34x and 45x a 200K context window**. One forgotten LIMIT instantly + overflows any agent session. The MCP server has **no** built-in row cap. +- **CLI bare is *worse* than MCP bare** (+33%) because CLI's JSON envelope is + fatter. So "CLI saves tokens" does **not** hold for unguarded scans. +- **CLI's `--limit` is an explicit opt-in guard, not a default.** `applyLimit` + is a no-op without `--limit`, so forgetting it is identical to MCP. Its value + is a structured flag independent of the SQL string that a skill can enforce + or recommend - the tool itself does not protect you. + +**`vs MCP` column sign**: `+` = more tokens than MCP (worse), `-` = fewer +(better). `-100%` is rounding of -99.94%. diff --git a/scripts/token-shootout.py b/scripts/token-shootout.py new file mode 100644 index 0000000..c0a6d07 --- /dev/null +++ b/scripts/token-shootout.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +"""mysql-cli vs mysql-mcp: A/B shootout. + +Measures latency, response bytes, and estimated tokens (bytes / 4) for the same +query tasks via two paths to the SAME database: + - MCP: mysql-mcp-server over stdio JSON-RPC (the path Claude Code uses) + - CLI: mysql-cli subcommands over subprocess (the shell-agent path) + +Env is read from .mcp.json and injected into BOTH paths so they hit the same DB +with the same credentials -- zero config drift. + +Token estimate is bytes / 4, a rough proxy. Real token counts live only in the +Claude Code transcript, which this script cannot see. Use BYTES as the honest +comparison axis; treat the tokens column as an order-of-magnitude hint. + +Usage: + python3 scripts/token-shootout.py # default table + python3 scripts/token-shootout.py --table users # real table + python3 scripts/token-shootout.py --out result.md -v +""" +import argparse +import json +import os +import shutil +import subprocess +import sys +import threading +import time +from collections import defaultdict +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +MCP_JSON = REPO / ".mcp.json" +DEFAULT_TABLE = "information_schema.tables" +BYTES_PER_TOKEN = 4 + + +# ---------- locate mysql-cli ---------- +def find_cli(override): + if override: + return override + candidates = [ + shutil.which("mysql-cli"), + str(Path.home() / "go" / "bin" / "mysql-cli"), + str(Path.home() / ".local" / "bin" / "mysql-cli"), + str(REPO / "mysql-cli"), + ] + for c in candidates: + if c and Path(c).exists(): + return c + sys.exit("mysql-cli not found. Pass --cli /path/to/mysql-cli or build it " + "with `go build -o mysql-cli ./cmd/mysql-cli`.") + + +# ---------- load .mcp.json ---------- +def load_mcp_config(): + if not MCP_JSON.exists(): + sys.exit(f".mcp.json not found at {MCP_JSON}") + cfg = json.loads(MCP_JSON.read_text()) + srv = cfg["mcpServers"]["mysql"] + return srv["command"], srv["args"], dict(srv.get("env", {})) + + +# ---------- MCP stdio JSON-RPC client ---------- +class McpClient: + def __init__(self, cmd, args, env, verbose=False): + self.verbose = verbose + self.proc = subprocess.Popen( + [cmd] + args, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=env, text=True, bufsize=1, + ) + self._id = 0 + self._tool_params = {} # tool name -> set(param names) + self._stderr_lines = [] + threading.Thread(target=self._drain_stderr, daemon=True).start() + self._init() + self._probe_tools() + + def _drain_stderr(self): + for line in self.proc.stderr: + self._stderr_lines.append(line) + if self.verbose: + sys.stderr.write(f"[mcp stderr] {line}") + + def _next(self): + self._id += 1 + return self._id + + def _send(self, obj): + self.proc.stdin.write(json.dumps(obj) + "\n") + self.proc.stdin.flush() + + def _recv(self, want_id, timeout=180): + end = time.time() + timeout + while time.time() < end: + line = self.proc.stdout.readline() + if not line: + tail = "".join(self._stderr_lines[-10:]) + raise RuntimeError(f"MCP EOF waiting for id={want_id}. stderr tail:\n{tail}") + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + if self.verbose: + sys.stderr.write(f"[mcp non-json] {line[:200]}\n") + continue + if obj.get("id") == want_id: + return obj + raise TimeoutError(f"MCP timeout waiting for id={want_id}") + + def _init(self): + i = self._next() + self._send({"jsonrpc": "2.0", "id": i, "method": "initialize", + "params": {"protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "shootout", "version": "0.1"}}}) + r = self._recv(i) + if "error" in r: + raise RuntimeError(f"initialize failed: {r['error']}") + self._send({"jsonrpc": "2.0", "method": "notifications/initialized"}) + + def _probe_tools(self): + i = self._next() + self._send({"jsonrpc": "2.0", "id": i, "method": "tools/list", "params": {}}) + r = self._recv(i) + for t in r.get("result", {}).get("tools", []): + props = t.get("inputSchema", {}).get("properties", {}) + self._tool_params[t["name"]] = set(props.keys()) + if self.verbose: + sys.stderr.write(f"[mcp tools] {sorted(self._tool_params)}\n") + for n, p in self._tool_params.items(): + sys.stderr.write(f" {n}: {sorted(p)}\n") + + def call(self, tool, args): + i = self._next() + self._send({"jsonrpc": "2.0", "id": i, "method": "tools/call", + "params": {"name": tool, "arguments": args}}) + return self._recv(i) + + def param_of(self, tool, candidates): + """Pick the real param name from candidates using the probed schema.""" + have = self._tool_params.get(tool, set()) + for c in candidates: + if c in have: + return c + return candidates[0] # best-effort fallback + + def close(self): + try: + self.proc.terminate() + self.proc.wait(timeout=5) + except Exception: + self.proc.kill() + + +def mcp_extract(resp): + """Return (text, ok, err_msg) from a tools/call response.""" + if "error" in resp: + return "", False, json.dumps(resp["error"])[:160] + result = resp.get("result", {}) + text = "\n".join(c.get("text", "") for c in result.get("content", []) + if c.get("type") == "text") + if result.get("isError"): + return text, False, text[:160] + return text, True, "" + + +# ---------- CLI client ---------- +def run_cli(cli, args, env, timeout=180): + t0 = time.perf_counter() + p = subprocess.run([cli] + args, capture_output=True, text=True, + env=env, timeout=timeout) + dt = (time.perf_counter() - t0) * 1000 + return dt, p.stdout, p.returncode, p.stderr + + +# ---------- task definitions ---------- +def build_tasks(table, include_nolimit=False): + sample_sql = f"SELECT * FROM {table} LIMIT 20" + tasks = [ + {"name": "list_tables", + "runs": [ + ("MCP execute_sql", "mcp_sql", "SHOW TABLES"), + ("CLI tables", "cli_tables", None), + ("CLI query --format table", "cli_query", ("SHOW TABLES", "table")), + ]}, + {"name": f"schema({table})", + "runs": [ + ("MCP get_schema_info", "mcp_schema", table), + ("CLI schema", "cli_schema", table), + ]}, + {"name": f"sample({table} LIMIT 20)", + "runs": [ + ("MCP execute_sql", "mcp_sql", sample_sql), + ("CLI query --format json", "cli_query", (sample_sql, "json")), + ("CLI query --format table", "cli_query", (sample_sql, "table")), + ("CLI query --format csv", "cli_query", (sample_sql, "csv")), + ]}, + ] + if include_nolimit: + # Full-table scan with NO LIMIT. Reveals whether each path self-limits: + # MCP execute_sql -> does the server cap rows on its own? + # CLI query -> applyLimit is a no-op without --limit, so bare. + # CLI query --limit 20 -> the explicit guard, for contrast. + full_sql = f"SELECT * FROM {table}" + tasks.append({ + "name": f"no_limit_full_scan({table})", + "runs": [ + ("MCP execute_sql (no LIMIT)", "mcp_sql", full_sql), + ("CLI query (no --limit)", "cli_query_raw", full_sql), + ("CLI query --limit 20", "cli_query_limited", (full_sql, "json", 20)), + ], + }) + return tasks + + +# ---------- measure one run ---------- +def measure(run, mcp, cli, env): + label, kind, payload = run + try: + if kind == "mcp_sql": + p = mcp.param_of("execute_sql", ["query", "sql", "statement"]) + t0 = time.perf_counter() + r = mcp.call("execute_sql", {p: payload}) + dt = (time.perf_counter() - t0) * 1000 + out, ok, err = mcp_extract(r) + elif kind == "mcp_schema": + p = mcp.param_of("get_schema_info", ["table_name", "table"]) + t0 = time.perf_counter() + r = mcp.call("get_schema_info", {p: payload}) + dt = (time.perf_counter() - t0) * 1000 + out, ok, err = mcp_extract(r) + elif kind == "cli_tables": + dt, out, rc, err = run_cli(cli, ["tables", "--format", "json"], env) + ok = rc == 0 + err = err[:160] + elif kind == "cli_schema": + dt, out, rc, err = run_cli(cli, ["schema", payload, "--format", "json"], env) + ok = rc == 0 + err = err[:160] + elif kind == "cli_query": + sql, fmt = payload + dt, out, rc, err = run_cli(cli, ["query", sql, "--format", fmt], env) + ok = rc == 0 + err = err[:160] + elif kind == "cli_query_raw": + # Bare SELECT with NO --limit: tests whether CLI (like MCP) returns + # the full result set when the guard is absent. applyLimit is a + # no-op when --limit is unset, so this is a true full-table scan. + # --timeout extends the CLI's internal query timeout past the 30s + # default so a large table doesn't trip it. + sql = payload + dt, out, rc, err = run_cli(cli, ["query", sql, "--format", "json", + "--timeout", "120s"], env) + ok = rc == 0 + err = err[:160] + elif kind == "cli_query_limited": + # Same bare SQL, but with the explicit --limit guard. applyLimit + # wraps it as `SELECT * FROM () AS _q LIMIT N`. Contrast with + # cli_query_raw to quantify the guard's value. + sql, fmt, limit = payload + dt, out, rc, err = run_cli(cli, ["query", sql, "--format", fmt, + "--limit", str(limit)], env) + ok = rc == 0 + err = err[:160] + else: + return label, "", 0, 0, False, "unknown kind" + except Exception as e: + return label, "", 0, 0, False, f"exc: {e}"[:160] + n = len(out.encode("utf-8")) if out else 0 + return label, out, dt, n, ok, err + + +# ---------- markdown render ---------- +def fmt_num(n): + return f"{n:,}" + + +def render(tasks, results, out_file, env): + lines = [] + lines.append("# mysql-cli vs mysql-mcp: A/B shootout\n") + lines.append(f"- DB: `{env.get('MYSQL_HOST', '?')}` / `{env.get('MYSQL_DATABASE', '?')}`") + lines.append(f"- Token estimate = response bytes ÷ {BYTES_PER_TOKEN} (rough proxy; " + "real tokens live in the Claude Code transcript).") + lines.append("- Latency is end-to-end per call (ms). CLI pays process fork + connect " + "each call; MCP reuses a warm process + connection pool.\n") + + summary = [] + for task, runs in zip(tasks, results): + lines.append(f"## {task['name']}\n") + lines.append("| method | latency (ms) | bytes | ≈tokens | status |") + lines.append("|---|---:|---:|---:|---|") + for (label, _out, dt, n, ok, err) in runs: + tok = n // BYTES_PER_TOKEN + status = "ok" if ok else f"FAIL: {err}" + lines.append(f"| {label} | {dt:.0f} | {fmt_num(n)} | {fmt_num(tok)} | {status} |") + summary.append((task["name"], label, dt, n, tok, ok)) + lines.append("") + + lines.append("## Summary (≈tokens, vs MCP baseline per task)\n") + lines.append("| task | method | ≈tokens | vs MCP |") + lines.append("|---|---|---:|---:|") + by_task = defaultdict(list) + for row in summary: + by_task[row[0]].append(row) + for tname, items in by_task.items(): + mcp_tok = next((it[4] for it in items if it[1].startswith("MCP")), None) + for (_t, label, _dt, _n, tok, _ok) in items: + if label.startswith("MCP"): + vs = "baseline" + elif mcp_tok and mcp_tok > 0: + # + = more tokens than MCP (worse), - = fewer (better). + vs = f"{(tok / mcp_tok - 1) * 100:+.0f}%" + else: + vs = "n/a" + lines.append(f"| {tname} | {label} | {fmt_num(tok)} | {vs} |") + lines.append("") + + out = "\n".join(lines) + if out_file: + Path(out_file).write_text(out) + print(f"written: {out_file}", file=sys.stderr) + print(out) + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--table", default=DEFAULT_TABLE, + help=f"table for schema/sample tasks (default: {DEFAULT_TABLE})") + ap.add_argument("--cli", default=None, help="path to mysql-cli binary (default: auto-detect)") + ap.add_argument("--out", default=None, help="also write markdown to this file") + ap.add_argument("--no-limit", action="store_true", + help="add a full-table-scan task: bare SELECT via MCP/CLI " + "vs CLI --limit guard (heavy; can pull the whole table)") + ap.add_argument("-v", "--verbose", action="store_true", + help="show MCP tool list / stderr / non-json lines") + args = ap.parse_args() + + cli = find_cli(args.cli) + mcp_cmd, mcp_args, mcp_env = load_mcp_config() + # Both subprocesses need PATH (to find uvx / mysql-cli) PLUS the MYSQL_* + # creds from .mcp.json. Build one shared env so both hit the same DB. + proc_env = dict(os.environ) + proc_env.update(mcp_env) + + print(f"mysql-cli : {cli}", file=sys.stderr) + print(f"mcp server: {mcp_cmd} {' '.join(mcp_args)}", file=sys.stderr) + print(f"db : {mcp_env.get('MYSQL_HOST')}/{mcp_env.get('MYSQL_DATABASE')}", file=sys.stderr) + + mcp = McpClient(mcp_cmd, mcp_args, proc_env, verbose=args.verbose) + try: + # Warm up MCP so measured calls reflect the hot path (Claude Code keeps + # the server resident + connection warm). CLI is a short-lived process + # by design, so its per-call fork+connect cost stays in the numbers on + # purpose -- that is the real shell-agent cost model. + try: + qp = mcp.param_of("execute_sql", ["query", "sql", "statement"]) + mcp.call("execute_sql", {qp: "SELECT 1"}) + print(" (mcp warm-up: SELECT 1)", file=sys.stderr) + except Exception as e: + print(f" (mcp warm-up failed: {e})", file=sys.stderr) + tasks = build_tasks(args.table, args.no_limit) + results = [] + for task in tasks: + runs = [] + for run in task["runs"]: + r = measure(run, mcp, cli, proc_env) + runs.append(r) + flag = "ok" if r[4] else "FAIL" + print(f" {task['name']:<30} {r[0]:<26} {r[2]:>6.0f}ms " + f"{fmt_num(r[3]):>9}B {flag}", file=sys.stderr) + results.append(runs) + render(tasks, results, args.out, mcp_env) + finally: + mcp.close() + + +if __name__ == "__main__": + main() From 4e920590e286062e1666dbaa63fad55a956687aa Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 13:31:41 +0800 Subject: [PATCH 02/29] docs(plan): add default-limit + envelope-slim implementation plan --- .../2026-07-24-mysql-cli-default-limit.md | 839 ++++++++++++++++++ 1 file changed, 839 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-mysql-cli-default-limit.md diff --git a/docs/superpowers/plans/2026-07-24-mysql-cli-default-limit.md b/docs/superpowers/plans/2026-07-24-mysql-cli-default-limit.md new file mode 100644 index 0000000..f96ae44 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-mysql-cli-default-limit.md @@ -0,0 +1,839 @@ +# mysql-cli 默认安全 LIMIT + 信封瘦身 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 给 mysql-cli 的 SELECT 加默认安全行数 cap(默认 1000,cap+1 探测截断,`meta.truncated` 标记),并瘦身 JSON 信封(SELECT 省 `rows_affected` + 新增 `--format jsonl`),消灭裸跑全表扫的 token 灾难。 + +**Architecture:** 默认 cap 在 query 层实现:`applyLimit` 在 probe 模式 wrap `LIMIT cap+1`,`Execute` 扫描后按行数判定截断并设 `result.Result.Truncated`。truncated 经 Result 传到 cli,cli 按 read/write 分流到 `format.ReadJSON`(省 rows_affected + `meta{truncated,limit}`)或 `SuccessJSON`。开关:`--no-limit` flag、config `default_limit`、env `MYSQL_CLI_DEFAULT_LIMIT`,优先级 `--limit` > `--no-limit` > config > env > 1000。 + +**Tech Stack:** Go 1.22+, spf13/cobra, DATA-DOG/go-sqlmock, stretchr/testify, BurntSushi/toml, olekukonko/tablewriter + +## Global Constraints + +- Go 1.22+;`go build ./...`、`go vet ./...`、`go test ./...` 必须通过 +- 退出码契约不变(truncated 在 meta,不新增退出码;`Exit*` 常量不动) +- `internal/result` 保持 dependency-free(不加 meta map;`Truncated bool` 字段可接受) +- `applyLimit`/`hasLimit`/`selectRe` 未导出,测试在 `query` 包内可直接调 +- skill 改完 `go build` 重新 embed(bundle);`./scripts/skill-format-check.sh skills/` 必须过 +- conventional commits;每个 task 末尾 commit +- 集成测试 testcontainers 需 `RUN_INTEGRATION=1`,默认跳过 +- breaking change:SELECT 默认 cap 1000 + SELECT json 省 `rows_affected`,CHANGELOG 明示 + +## File Structure + +| 文件 | 责任 | 改动 | +|---|---|---| +| `internal/result/result.go` | 无依赖结果契约 | 加 `Truncated bool` 字段 | +| `internal/query/query.go` | 读查询执行 + LIMIT wrap | `Options.Probe`;`applyLimit(sql,limit,probe)`;Execute 截断 | +| `internal/query/query_test.go` | query 单测(sqlmock) | probe 截断两分支测试 | +| `internal/config/config.go` | TOML/env 配置 | `Config.DefaultLimit` + `fileConfig` + `LoadFile` 映射 | +| `internal/config/config_test.go` | config 单测 | `default_limit` 解析测试 | +| `internal/format/format.go` | 输出渲染 | `ReadJSON(r,limit)` + `jsonl` 分支 | +| `internal/format/format_test.go` | format 单测 | ReadJSON/jsonl 测试 | +| `internal/cli/root.go` | cobra 装配 + 全局 flag | `--no-limit` flag;`Globals.NoLimit/DefaultLimit/eout`;jsonl 校验 | +| `internal/cli/commands.go` | 子命令 + emit | `resolveCap`/`defaultCap`/`emitReadResult`;newQueryCmd read 分流 | +| `internal/cli/commands_test.go` | cli 单测 | cap 优先级 + emit 测试 | +| `skills/mysql-shared/SKILL.md` | 共享规则 | 默认 cap + truncated + --no-limit + jsonl;version 1.1.0 | +| `skills/mysql-query/SKILL.md` | query 技能 | agent 适配指引;version 1.1.0 | +| `CHANGELOG.md` | 变更记录 | breaking 条目 | + +--- + +### Task 1: result.Result 加 Truncated 字段 + +**Files:** +- Modify: `internal/result/result.go:9-14` +- Test: `internal/result/result_test.go` + +**Interfaces:** +- Produces: `result.Result.Truncated bool`(后续 query/format/cli 任务依赖) + +- [ ] **Step 1: Write the failing test** + +追加到 `internal/result/result_test.go` 末尾: + +```go +func TestTruncatedField(t *testing.T) { + r := Result{Columns: []string{"id"}, Rows: [][]any{{1}}, Truncated: true} + assert.True(t, r.Truncated) + + zero := Result{} + assert.False(t, zero.Truncated) // 零值为 false +} +``` + +若 `result_test.go` 顶部没有 `import "github.com/stretchr/testify/assert"`,补上(参考现有测试)。 + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/result/ -run TestTruncatedField -v` +Expected: FAIL / 编译错误 `unknown field 'Truncated' in struct literal of type Result` + +- [ ] **Step 3: Write minimal implementation** + +修改 `internal/result/result.go` 的 `Result` 结构体(原 9-14 行): + +```go +type Result struct { + Columns []string + Rows [][]any + RowsAffected int64 + LastInsertID int64 + Truncated bool +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/result/ -v` +Expected: PASS(含新测试 + 现有测试) + +- [ ] **Step 5: Commit** + +```bash +git add internal/result/result.go internal/result/result_test.go +git commit -m "feat(result): add Truncated field to Result" +``` + +--- + +### Task 2: query 层默认 cap + cap+1 探测截断 + +**Files:** +- Modify: `internal/query/query.go:19-25`(Options)、`query.go:48`(Execute 调 applyLimit)、`query.go:92-105`(applyLimit)、`query.go:69-86`(Execute 扫描循环) +- Test: `internal/query/query_test.go` + +**Interfaces:** +- Consumes: `result.Result.Truncated`(Task 1) +- Produces: `query.Options.Probe bool`;`Execute` 在 probe 模式设 `r.Truncated` 并截断行;`applyLimit(sqlText, limit, probe)` 签名 + +- [ ] **Step 1: Write the failing tests** + +追加到 `internal/query/query_test.go` 末尾: + +```go +func TestApplyLimitProbe(t *testing.T) { + tests := []struct { + name string + sql string + limit int + probe bool + expected string + }{ + {name: "probe wraps limit+1", sql: "SELECT id FROM t", limit: 100, probe: true, expected: "SELECT * FROM (SELECT id FROM t) AS _q LIMIT 101"}, + {name: "no probe wraps limit", sql: "SELECT id FROM t", limit: 100, probe: false, expected: "SELECT * FROM (SELECT id FROM t) AS _q LIMIT 100"}, + {name: "probe ignored when limit<=0", sql: "SELECT id FROM t", limit: 0, probe: true, expected: "SELECT id FROM t"}, + {name: "probe ignored when hasLimit", sql: "SELECT id FROM t LIMIT 5", limit: 100, probe: true, expected: "SELECT id FROM t LIMIT 5"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, applyLimit(tt.sql, tt.limit, tt.probe)) + }) + } +} + +func TestExecuteProbeTruncates(t *testing.T) { + pool, mock := newMock(t) + // probe limit=2 -> wraps LIMIT 3, 返回 3 行 -> truncated, 保留 2 行 + rows := sqlmock.NewRows([]string{"id"}).AddRow(1).AddRow(2).AddRow(3) + mock.ExpectQuery("SELECT \\* FROM \\(SELECT id FROM t\\) AS _q LIMIT 3").WillReturnRows(rows) + r, err := Execute(context.Background(), pool, "SELECT id FROM t", Options{Limit: 2, Probe: true}) + assert.NoError(t, err) + assert.True(t, r.Truncated) + assert.Equal(t, 2, len(r.Rows)) +} + +func TestExecuteProbeNoTruncate(t *testing.T) { + pool, mock := newMock(t) + // probe limit=2 -> wraps LIMIT 3, 返回 2 行(<=limit) -> 未截断 + rows := sqlmock.NewRows([]string{"id"}).AddRow(1).AddRow(2) + mock.ExpectQuery("SELECT \\* FROM \\(SELECT id FROM t\\) AS _q LIMIT 3").WillReturnRows(rows) + r, err := Execute(context.Background(), pool, "SELECT id FROM t", Options{Limit: 2, Probe: true}) + assert.NoError(t, err) + assert.False(t, r.Truncated) + assert.Equal(t, 2, len(r.Rows)) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/query/ -run 'TestApplyLimitProbe|TestExecuteProbe' -v` +Expected: FAIL / 编译错误(`applyLimit` 签名不匹配;`Options` 无 `Probe`) + +- [ ] **Step 3: Update applyLimit signature + Options** + +`internal/query/query.go` Options(原 19-25 行): + +```go +type Options struct { + Write bool + DDL bool + Yes bool + Limit int + Probe bool + Timeout time.Duration +} +``` + +`applyLimit`(原 92-105 行): + +```go +// applyLimit wraps a SELECT with an outer LIMIT when one is requested and +// the statement is a read query without its own LIMIT. In probe mode it +// requests limit+1 rows so the caller can detect truncation. +func applyLimit(sqlText string, limit int, probe bool) string { + if limit <= 0 || !selectRe.MatchString(sqlText) { + return sqlText + } + if hasLimit(sqlText) { + return sqlText + } + cleaned := strings.TrimRight(strings.TrimSpace(sqlText), ";") + n := limit + if probe { + n = limit + 1 + } + return fmt.Sprintf("SELECT * FROM (%s) AS _q LIMIT %d", cleaned, n) +} +``` + +- [ ] **Step 4: Update Execute to pass probe + truncate** + +`internal/query/query.go` Execute 中(原 48 行)把: + +```go + execSQL := applyLimit(sqlText, opts.Limit) +``` + +改为: + +```go + execSQL := applyLimit(sqlText, opts.Limit, opts.Probe) +``` + +在 Execute 的 `return res, nil` 之前(原 89 行前,`rows.Err()` 检查之后)插入截断逻辑: + +```go + if opts.Probe && opts.Limit > 0 && len(res.Rows) > opts.Limit { + res.Truncated = true + res.Rows = res.Rows[:opts.Limit] + } + return res, nil +``` + +- [ ] **Step 5: Update existing TestApplyLimit call sites** + +`internal/query/query_test.go` 的 `TestApplyLimit`(原 67-111)所有 `applyLimit(tt.sql, tt.limit)` 调用改为 `applyLimit(tt.sql, tt.limit, false)`: + +```go + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, applyLimit(tt.sql, tt.limit, false)) + }) +``` + +`TestApplyLimitIgnoresLimitInStringLiteral`(原 113-119)的 `applyLimit(sql, 100)` 改为 `applyLimit(sql, 100, false)`。 + +- [ ] **Step 6: Run all query tests** + +Run: `go test ./internal/query/ -v` +Expected: PASS(新测试 + 现有 TestApplyLimit/TestExecuteLimitWrapsQuery 等全过) + +- [ ] **Step 7: Commit** + +```bash +git add internal/query/query.go internal/query/query_test.go +git commit -m "feat(query): default safe cap with cap+1 probe + Truncated" +``` + +--- + +### Task 3: config 加 DefaultLimit + +**Files:** +- Modify: `internal/config/config.go:47-50`(Config)、`config.go:52-82`(fileConfig)、`config.go:86-97`(LoadFile) +- Test: `internal/config/config_test.go` + +**Interfaces:** +- Produces: `config.Config.DefaultLimit int`(toml `default_limit`);cli Task 5 读取 + +- [ ] **Step 1: Write the failing test** + +追加到 `internal/config/config_test.go` 末尾(参考现有 LoadFile 测试的 toml 字符串构造方式): + +```go +func TestDefaultLimitFromConfig(t *testing.T) { + toml := ` +default = "dev" +default_limit = 2500 + +[datasource.dev] +host = "127.0.0.1" +port = 3306 +` + tmp := t.TempDir() + "/config.toml" + assert.NoError(t, os.WriteFile(tmp, []byte(toml), 0644)) + cfg, err := LoadFile(tmp) + assert.NoError(t, err) + assert.Equal(t, 2500, cfg.DefaultLimit) +} + +func TestDefaultLimitZeroWhenUnset(t *testing.T) { + toml := ` +default = "dev" +[datasource.dev] +host = "127.0.0.1" +` + tmp := t.TempDir() + "/config.toml" + assert.NoError(t, os.WriteFile(tmp, []byte(toml), 0644)) + cfg, err := LoadFile(tmp) + assert.NoError(t, err) + assert.Equal(t, 0, cfg.DefaultLimit) +} +``` + +确保 `config_test.go` 已 import `os`(若没有则补)。 + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/config/ -run TestDefaultLimit -v` +Expected: FAIL(`cfg.DefaultLimit` undefined) + +- [ ] **Step 3: Add DefaultLimit to Config + fileConfig + LoadFile** + +`internal/config/config.go` Config(原 47-50 行): + +```go +type Config struct { + Datasources map[string]Datasource `toml:"datasource"` + DefaultDatasource string `toml:"default"` + DefaultLimit int `toml:"default_limit"` +} +``` + +`fileConfig` 结构(原 52-82 行,找到 `type fileConfig struct` 那个,在 `Default` 字段后加 `DefaultLimit`): + +```go +type fileConfig struct { + Default string `toml:"default"` + DefaultLimit int `toml:"default_limit"` + Datasources map[string]fileDatasource `toml:"datasource"` +} +``` + +(保留 fileConfig 原有其他字段,只插入 `DefaultLimit`。) + +`LoadFile`(原 86-97 行)的 cfg 构造改为: + +```go + cfg := &Config{DefaultDatasource: fc.Default, DefaultLimit: fc.DefaultLimit, Datasources: map[string]Datasource{}} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/config/ -v` +Expected: PASS(新测试 + 现有 config 测试全过) + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/config.go internal/config/config_test.go +git commit -m "feat(config): add default_limit config field" +``` + +--- + +### Task 4: format.ReadJSON + jsonl 格式 + +**Files:** +- Modify: `internal/format/format.go`(新增 ReadJSON、jsonl 分支、formatJSONL) +- Test: `internal/format/format_test.go` + +**Interfaces:** +- Consumes: `result.Result.Truncated`(Task 1) +- Produces: `format.ReadJSON(r result.Result, limit int) string`;`Format(r, "jsonl")` 分支。cli Task 5 调用 + +- [ ] **Step 1: Write the failing tests** + +追加到 `internal/format/format_test.go` 末尾: + +```go +func TestReadJSONOmitsRowsAffectedAndAddsMeta(t *testing.T) { + r := result.Result{Columns: []string{"id"}, Rows: [][]any{{1}}, Truncated: true} + out := ReadJSON(r, 1000) + var env struct { + Success bool `json:"success"` + Data struct{ Rows [][]any `json:"rows"` } `json:"data"` + RowsAffected *int `json:"rows_affected"` // 指针:缺省时为 nil + Meta map[string]any `json:"meta"` + } + assert.NoError(t, json.Unmarshal([]byte(out), &env)) + assert.True(t, env.Success) + assert.Nil(t, env.RowsAffected) // SELECT 省略 + assert.Equal(t, true, env.Meta["truncated"]) + assert.Equal(t, float64(1000), env.Meta["limit"]) +} + +func TestJSONL(t *testing.T) { + r := result.Result{Columns: []string{"id", "name"}, Rows: [][]any{{1, "a"}, {nil, "b"}}} + out, err := Format(r, "jsonl") + assert.NoError(t, err) + assert.Equal(t, `{"id":1,"name":"a"}`+"\n"+`{"id":null,"name":"b"}`+"\n", out) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/format/ -run 'TestReadJSON|TestJSONL' -v` +Expected: FAIL(`ReadJSON` undefined;`Format(r,"jsonl")` error "unknown format") + +- [ ] **Step 3: Add ReadJSON + jsonl** + +`internal/format/format.go` 在 `SuccessJSON` 函数之后新增: + +```go +// ReadJSON renders the success envelope for a read query: omits rows_affected +// (always 0 for SELECT) and reports truncated/limit in meta. +func ReadJSON(r result.Result, limit int) string { + env := map[string]any{ + "success": true, + "data": map[string]any{ + "columns": r.Columns, + "rows": r.Rows, + }, + "meta": map[string]any{ + "truncated": r.Truncated, + "limit": limit, + }, + } + b, err := json.Marshal(env) + if err != nil { + return ErrorJSON("FORMAT_ERROR", "json marshal failed: "+err.Error()) + } + return string(b) +} +``` + +`Format` switch(原 56-69 行)在 `case "json":` 之后、`default` 之前加: + +```go + case "jsonl": + return formatJSONL(r), nil +``` + +文件末尾新增: + +```go +func formatJSONL(r result.Result) (string, error) { + var buf bytes.Buffer + for _, row := range r.Rows { + obj := make(map[string]any, len(row)) + for i, c := range row { + if i < len(r.Columns) { + obj[r.Columns[i]] = c + } + } + b, err := json.Marshal(obj) + if err != nil { + return "", err + } + buf.Write(b) + buf.WriteByte('\n') + } + return buf.String(), nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/format/ -v` +Expected: PASS(新测试 + 现有 TestJSONEnvelope/TestCSV 等全过;`SuccessJSON` 未改,行为不变) + +- [ ] **Step 5: Commit** + +```bash +git add internal/format/format.go internal/format/format_test.go +git commit -m "feat(format): ReadJSON envelope + jsonl format" +``` + +--- + +### Task 5: cli 集成 --no-limit + cap 优先级 + read 信封分流 + +**Files:** +- Modify: `internal/cli/root.go:32-47`(Globals)、`root.go:51`(Run 初始化)、`root.go:70-78`(PreRunE 校验)、`root.go:80-93`(flag 注册) +- Modify: `internal/cli/commands.go:27-43`(resolve 填 DefaultLimit)、`commands.go:53-65`(opts/emitResult)、`commands.go:92-104`(newQueryCmd read 分流) +- Test: `internal/cli/commands_test.go` + +**Interfaces:** +- Consumes: `Options.Probe`(Task 2)、`Config.DefaultLimit`(Task 3)、`format.ReadJSON`/`Format(r,"jsonl")`(Task 4) +- Produces:`--no-limit` flag;`--format jsonl`;默认 cap 行为 + +- [ ] **Step 1: Write the failing tests** + +追加到 `internal/cli/commands_test.go` 末尾(确保 import `bytes`、`result`、`assert`、`cobra`): + +```go +func TestDefaultCapFallbackTo1000(t *testing.T) { + g := &Globals{} + assert.Equal(t, 1000, g.defaultCap()) +} + +func TestDefaultCapFromConfig(t *testing.T) { + g := &Globals{DefaultLimit: 500} + assert.Equal(t, 500, g.defaultCap()) +} + +func TestDefaultCapFromEnv(t *testing.T) { + t.Setenv("MYSQL_CLI_DEFAULT_LIMIT", "200") + g := &Globals{} + assert.Equal(t, 200, g.defaultCap()) +} + +func TestResolveCapDefaultProbe(t *testing.T) { + g := &Globals{DefaultLimit: 500} + cmd := &cobra.Command{} + limit, probe := g.resolveCap(cmd) + assert.Equal(t, 500, limit) + assert.True(t, probe) +} + +func TestResolveCapNoLimitFlag(t *testing.T) { + g := &Globals{NoLimit: true} + cmd := &cobra.Command{} + limit, probe := g.resolveCap(cmd) + assert.Equal(t, 0, limit) + assert.False(t, probe) +} + +func TestResolveCapExplicitLimit(t *testing.T) { + g := &Globals{Limit: 50} + cmd := &cobra.Command{} + cmd.Flags().Int("limit", 0, "") + cmd.Flags().Set("limit", "50") + limit, probe := g.resolveCap(cmd) + assert.Equal(t, 50, limit) + assert.False(t, probe) +} + +func TestEmitReadJSONOmitsRowsAffected(t *testing.T) { + var out bytes.Buffer + g := &Globals{Format: "json", out: &out} + r := result.Result{Columns: []string{"id"}, Rows: [][]any{{1}}, Truncated: true} + g.emitReadResult(r, nil, 1000) + assert.Contains(t, out.String(), `"truncated":true`) + assert.NotContains(t, out.String(), "rows_affected") +} + +func TestEmitReadJSONLTruncatedStderr(t *testing.T) { + var out, eout bytes.Buffer + g := &Globals{Format: "jsonl", out: &out, eout: &eout} + r := result.Result{Columns: []string{"id"}, Rows: [][]any{{1}}, Truncated: true} + g.emitReadResult(r, nil, 1000) + assert.Contains(t, out.String(), `{"id":1}`) + assert.Contains(t, eout.String(), "# truncated:true limit:1000") +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/cli/ -run 'TestDefaultCap|TestResolveCap|TestEmitRead' -v` +Expected: FAIL(`Globals` 无 `NoLimit/DefaultLimit/eout`;`defaultCap/resolveCap/emitReadResult` undefined) + +- [ ] **Step 3: Extend Globals + register --no-limit + jsonl validation** + +`internal/cli/root.go` Globals(原 32-47 行)加三个字段: + +```go +type Globals struct { + Datasource string + Format string + Write bool + DDL bool + Yes bool + Limit int + NoLimit bool + DefaultLimit int + Timeout string + ConfigPath string + Host string + Port int + User string + Password string + Database string + out io.Writer + eout io.Writer +} +``` + +`Run`(原 51 行)初始化 eout: + +```go + g := &Globals{Format: "json", out: os.Stdout, eout: os.Stderr} +``` + +`PersistentPreRunE`(原 70-78 行)的 format 校验加 jsonl: + +```go + if g.Format != "json" && g.Format != "table" && g.Format != "csv" && g.Format != "tsv" && g.Format != "jsonl" { + return fmt.Errorf("invalid format %q (want json|table|csv|tsv|jsonl)", g.Format) + } +``` + +flag 注册(原 80-93 行,在 `--limit` 那行之后)加: + +```go + pf.BoolVar(&g.NoLimit, "no-limit", false, "disable default row cap for SELECT (returns full result set)") +``` + +`--format` 的 help 文案(原 82 行)改为: + +```go + pf.StringVarP(&g.Format, "format", "f", "json", "output format: json|table|csv|tsv|jsonl") +``` + +- [ ] **Step 4: Add resolveCap / defaultCap / emitReadResult;wire newQueryCmd** + +`internal/cli/commands.go` import 块(原 3-17 行)加 `"strconv"`(若没有)。 + +`resolve()`(原 27-43 行)在 `cfg, err = config.LoadFile(...)` 成功分支后、`over := ...` 之前加: + +```go + if cfg != nil { + g.DefaultLimit = cfg.DefaultLimit + } +``` + +(放在 `if _, err := os.Stat(...); err == nil { ... }` 块内,`LoadFile` 成功后。) + +在 `opts()`(原 53-56 行)之后新增三个方法: + +```go +// defaultCap resolves the default row cap: config > env > built-in 1000. +func (g *Globals) defaultCap() int { + if g.DefaultLimit > 0 { + return g.DefaultLimit + } + if v, err := strconv.Atoi(os.Getenv("MYSQL_CLI_DEFAULT_LIMIT")); err == nil && v > 0 { + return v + } + return 1000 +} + +// resolveCap decides (limit, probe) for a read query: +// --no-limit -> (0, false) no cap +// --limit explicit -> (g.Limit, false) exact N, no probe +// otherwise -> (defaultCap, true) default cap with cap+1 probe +func (g *Globals) resolveCap(cmd *cobra.Command) (int, bool) { + if g.NoLimit { + return 0, false + } + if cmd.Flags().Changed("limit") { + return g.Limit, false + } + return g.defaultCap(), true +} + +// emitReadResult renders a read query result: json -> ReadJSON (slim envelope), +// jsonl -> line stream + stderr truncated notice, else -> Format. +func (g *Globals) emitReadResult(r result.Result, err error, limit int) { + if err != nil { + fmt.Fprintln(g.out, formatErr(err, g.Format)) + return + } + switch g.Format { + case "json": + fmt.Fprint(g.out, format.ReadJSON(r, limit)) + case "jsonl": + out, _ := format.Format(r, "jsonl") + fmt.Fprint(g.out, out) + if r.Truncated { + fmt.Fprintf(g.eout, "# truncated:true limit:%d\n", limit) + } + default: + out, _ := format.Format(r, g.Format) + fmt.Fprint(g.out, out) + } +} +``` + +`newQueryCmd` 的 RunE(原 92-104 行)switch 块改为: + +```go + ctx := context.Background() + var r result.Result + switch safety.Classify(sqlText) { + case safety.CategoryRead, safety.CategoryUnknown: + opts := g.opts() + opts.Limit, opts.Probe = g.resolveCap(cmd) + r, err = query.Execute(ctx, pool, sqlText, opts) + g.emitReadResult(r, err, opts.Limit) + default: + r, err = query.ExecuteWrite(ctx, pool, sqlText, g.opts()) + g.emitResult(r, err) + } + return err +``` + +(删除原 `g.emitResult(r, err)` 统一调用;read 走 emitReadResult,write 走 emitResult。) + +- [ ] **Step 5: Run all cli tests + full build** + +Run: `go test ./internal/cli/ -v` +Expected: PASS(新测试 + 现有 cli 测试全过) + +Run: `go build ./... && go vet ./...` +Expected: 无错误 + +- [ ] **Step 6: Commit** + +```bash +git add internal/cli/root.go internal/cli/commands.go internal/cli/commands_test.go +git commit -m "feat(cli): --no-limit flag, default cap priority, read envelope routing" +``` + +--- + +### Task 6: skill 更新 + version bump + +**Files:** +- Modify: `skills/mysql-shared/SKILL.md`(frontmatter version + 默认 cap/--no-limit/jsonl 章节) +- Modify: `skills/mysql-query/SKILL.md`(frontmatter version + agent 适配指引) + +**Interfaces:** +- Produces: skill 文档更新;bundle `go build` 后重新 embed + +- [ ] **Step 1: Update mysql-shared frontmatter + content** + +`skills/mysql-shared/SKILL.md` frontmatter: + +- `version: 1.0.0` -> `version: 1.1.0` +- `output_formats: json | table | csv | tsv` -> `output_formats: json | table | csv | tsv | jsonl` +- `safety_model:` 那行追加 `; SELECT 默认 cap 1000 (--no-limit 关)` + +在 `## Output Formats / 输出格式` 章节(原 ~94 行)内追加一段: + +```markdown +### 默认行数 cap / Default row cap + +SELECT 不带 LIMIT 时,mysql-cli 默认只返回前 1000 行并在 `meta.truncated=true` 标记(用 cap+1 探测,零额外查询)。 + +- `--limit N`:显式要 N 行,精确返回,不探测截断 +- `--no-limit`:关闭默认 cap,返回全表(危险,可能撑爆 context) +- `default_limit`(config.toml 顶层)/ `MYSQL_CLI_DEFAULT_LIMIT`(env):调默认 cap 值 +- 优先级:`--limit` > `--no-limit` > config > env > 1000 +- 见 `truncated:true` 时,要全量需 `--no-limit` 或先 `SELECT COUNT(*)` 评估 + +`--format jsonl`:每行一个 JSON 对象(`{"col":val,...}`),NULL 为 `null`,比 json 省 token;截断信息走 stderr。 +``` + +- [ ] **Step 2: Update mysql-query frontmatter + content** + +`skills/mysql-query/SKILL.md` frontmatter:`version: 1.0.0` -> `version: 1.1.0`。 + +在 `## Notes / 备注` 章节(原 ~129 行)追加: + +```markdown +### 默认 cap 与截断 + +- SELECT 默认只返 1000 行;`meta.truncated=true` 表示被截断,需 `--no-limit` 或 `COUNT(*)` 评估全量后再决定。 +- 省 token:`--format jsonl`(紧凑)或 `--format csv`;避免 `--format table`(对 agent 极费 token)。 +- 确知要全表且可承受时才 `--no-limit`(实测 4.4 万行表裸跑 ≈900 万 token)。 +``` + +- [ ] **Step 3: Validate skill frontmatter + rebuild bundle** + +Run: `./scripts/skill-format-check.sh skills/` +Expected: 退出码 0,无报错 + +Run: `go build ./...` +Expected: 无错误(重新 embed 更新后的 skills/) + +- [ ] **Step 4: Commit** + +```bash +git add skills/mysql-shared/SKILL.md skills/mysql-query/SKILL.md +git commit -m "docs(skill): document default cap, --no-limit, jsonl; bump 1.1.0" +``` + +--- + +### Task 7: CHANGELOG + shootout 复测验证 + +**Files:** +- Create/Modify: `CHANGELOG.md`(若不存在则建) + +**Interfaces:** 无(文档 + 手动验证) + +- [ ] **Step 1: Add CHANGELOG entry** + +`CHANGELOG.md` 顶部(无文件则新建)加: + +```markdown +# Changelog + +## [Unreleased] + +### Breaking +- **SELECT 默认安全 cap 1000**:不带 LIMIT 的 SELECT 现在默认只返回 1000 行,`meta.truncated=true` 标记截断。需全表用 `--no-limit`;调默认值用 config `default_limit` 或 env `MYSQL_CLI_DEFAULT_LIMIT`;显式精确行数用 `--limit N`。动机:实测裸跑 4.4 万行表 = ~900 万 token(45 个 200K context 窗口),会当场撑爆 agent 会话。 +- **SELECT 的 JSON 信封省略 `rows_affected`**(对 SELECT 恒为 0);改用 `meta.truncated`/`meta.limit`。DML/DDL 信封不变。 + +### Added +- `--format jsonl`:每行一个 JSON 对象,比 json 紧凑,适合 agent。 +- `--no-limit` flag。 +- config `default_limit` / env `MYSQL_CLI_DEFAULT_LIMIT`。 +``` + +- [ ] **Step 2: Run full test suite** + +Run: `go test ./...` +Expected: PASS(所有单测,默认跳过集成) + +Run: `go test -cover ./...` +Expected: 覆盖率 ≥80%(项目历史区间 81%~92%) + +- [ ] **Step 3: shootout 复测验证默认 cap 生效** + +实现已编译安装(假设 `mysql-cli` 在 PATH 或用 `/Users/allenj/go/bin/mysql-cli`)。用 shootout 脚本对真实表验证:默认(无 --limit 无 --no-limit)应被截到 1000 行,`--no-limit` 仍全表。 + +Run(手动验证,连真实库): + +```bash +CLI=/Users/allenj/go/bin/mysql-cli +export $(python3 -c "import json;e=json.load(open('.mcp.json'))['mcpServers']['mysql']['env'];print(' '.join(f'{k}={v}' for k,v in e.items()))") +# 默认 cap:应返回 meta.truncated=true,约 1000 行 +"$CLI" query "SELECT * FROM sd_cx_order" --format json | python3 -c "import sys,json;d=json.load(sys.stdin);print('truncated=',d['meta']['truncated'],'rows=',len(d['data']['rows']))" +# --no-limit:应全表(44516 行),无 truncated +"$CLI" query "SELECT * FROM sd_cx_order" --no-limit --format json | python3 -c "import sys,json;d=json.load(sys.stdin);print('rows=',len(d['data']['rows']))" +# --limit 20:精确 20 行 +"$CLI" query "SELECT * FROM sd_cx_order" --limit 20 --format json | python3 -c "import sys,json;d=json.load(sys.stdin);print('rows=',len(d['data']['rows']))" +``` + +Expected: +- 默认:`truncated=True rows=1000` +- `--no-limit`:`rows=44516` +- `--limit 20`:`rows=20` + +- [ ] **Step 4: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs(changelog): breaking default cap + jsonl + --no-limit" +``` + +--- + +## Self-Review + +**Spec coverage:** +- §1 默认 cap 机制 + cap+1 探测 + 边界(已带 LIMIT 不动、SHOW 不 wrap、--limit 不探测) -> Task 2 +- §2.1 ReadJSON 省 rows_affected + meta.truncated + jsonl + stderr 截断 -> Task 4 + Task 5 +- §2.2 config default_limit + env + 优先级 -> Task 3 + Task 5(defaultCap) +- §2.3 --no-limit flag + 不新增退出码 + breaking -> Task 5 + Task 7 +- §2.4 skill 更新 + version bump -> Task 6 +- §2.5 单测 + 集成 + shootout 复测 -> 各 Task 测试 + Task 7 Step 2-3 + +**Placeholder scan:** 无 TBD/TODO;每个 code step 给完整代码。 + +**Type consistency:** `applyLimit(sqlText, limit, probe)` 在 Task 2 定义,Task 2 Step 5 更新所有调用点;`Options.Probe` 定义后 `g.opts()` 未加 Probe(默认 false),Task 5 在 read 分支显式设 `opts.Probe`;`ReadJSON(r, limit)` 定义于 Task 4,Task 5 emitReadResult 调用一致;`emitReadResult`/`resolveCap`/`defaultCap` 定义于 Task 5,测试与实现签名一致。 + +**注:** `SHOW`/`DESCRIBE` 不 wrap 由 `selectRe = ^\s*(SELECT|WITH)\b` 天然保证(Task 2 不改 selectRe),无需额外代码。`schema` 子命令走 `schema` 包不经 `query.Execute`,本期不改其信封(保持 `emitResult`->`SuccessJSON`),与 spec"SELECT 走 ReadJSON"一致(schema 命令非 SELECT)。 From 069b8203561d992cd7ffa34466c0cfbede67a80e Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 13:37:40 +0800 Subject: [PATCH 03/29] feat(result): add Truncated field to Result --- internal/result/result.go | 1 + internal/result/result_test.go | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/internal/result/result.go b/internal/result/result.go index caacb47..ed94894 100644 --- a/internal/result/result.go +++ b/internal/result/result.go @@ -11,6 +11,7 @@ type Result struct { Rows [][]any RowsAffected int64 LastInsertID int64 + Truncated bool } // Empty returns a zero-valued Result for operations that produce no rows. diff --git a/internal/result/result_test.go b/internal/result/result_test.go index d8ddb06..9b954d4 100644 --- a/internal/result/result_test.go +++ b/internal/result/result_test.go @@ -22,3 +22,11 @@ func TestResultHoldsData(t *testing.T) { assert.Equal(t, []string{"id", "name"}, r.Columns) assert.Equal(t, nil, r.Rows[1][0]) } + +func TestTruncatedField(t *testing.T) { + r := Result{Columns: []string{"id"}, Rows: [][]any{{1}}, Truncated: true} + assert.True(t, r.Truncated) + + zero := Result{} + assert.False(t, zero.Truncated) // 零值为 false +} From 613518efd4de35b0791bc8851f33dadf359d8d7c Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 13:41:44 +0800 Subject: [PATCH 04/29] feat(query): default safe cap with cap+1 probe + Truncated --- internal/query/query.go | 20 +++++++++++----- internal/query/query_test.go | 46 ++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/internal/query/query.go b/internal/query/query.go index 3dcf5b1..b6143ee 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -21,6 +21,7 @@ type Options struct { DDL bool Yes bool Limit int + Probe bool Timeout time.Duration } @@ -45,7 +46,7 @@ func Execute(ctx context.Context, pool *conn.Pool, sqlText string, opts Options) return result.Empty(), fmt.Errorf("%w: %v", ErrGuard, err) } - execSQL := applyLimit(sqlText, opts.Limit) + execSQL := applyLimit(sqlText, opts.Limit, opts.Probe) if opts.Timeout > 0 { var cancel context.CancelFunc @@ -86,22 +87,29 @@ func Execute(ctx context.Context, pool *conn.Pool, sqlText string, opts Options) if err := rows.Err(); err != nil { return result.Empty(), fmt.Errorf("%w: %v", ErrSQL, err) } + if opts.Probe && opts.Limit > 0 && len(res.Rows) > opts.Limit { + res.Truncated = true + res.Rows = res.Rows[:opts.Limit] + } return res, nil } // applyLimit wraps a SELECT with an outer LIMIT when one is requested and -// the statement is a read query without its own LIMIT. -func applyLimit(sqlText string, limit int) string { +// the statement is a read query without its own LIMIT. In probe mode it +// requests limit+1 rows so the caller can detect truncation. +func applyLimit(sqlText string, limit int, probe bool) string { if limit <= 0 || !selectRe.MatchString(sqlText) { return sqlText } if hasLimit(sqlText) { return sqlText } - // Strip a trailing semicolon (and surrounding whitespace) so the wrapped - // subquery remains valid SQL. cleaned := strings.TrimRight(strings.TrimSpace(sqlText), ";") - return fmt.Sprintf("SELECT * FROM (%s) AS _q LIMIT %d", cleaned, limit) + n := limit + if probe { + n = limit + 1 + } + return fmt.Sprintf("SELECT * FROM (%s) AS _q LIMIT %d", cleaned, n) } var ownLimitRe = regexp.MustCompile(`(?i)\bLIMIT\b\s+\d+`) diff --git a/internal/query/query_test.go b/internal/query/query_test.go index 5efbfea..e928c4f 100644 --- a/internal/query/query_test.go +++ b/internal/query/query_test.go @@ -105,7 +105,7 @@ func TestApplyLimit(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, applyLimit(tt.sql, tt.limit)) + assert.Equal(t, tt.expected, applyLimit(tt.sql, tt.limit, false)) }) } } @@ -115,5 +115,47 @@ func TestApplyLimitIgnoresLimitInStringLiteral(t *testing.T) { // treated as a real LIMIT clause, so the query is not wrapped. This is a // known limitation of the simple heuristic used by hasLimit. sql := "SELECT 'LIMIT 10' FROM t" - assert.Equal(t, sql, applyLimit(sql, 100)) + assert.Equal(t, sql, applyLimit(sql, 100, false)) +} + +func TestApplyLimitProbe(t *testing.T) { + tests := []struct { + name string + sql string + limit int + probe bool + expected string + }{ + {name: "probe wraps limit+1", sql: "SELECT id FROM t", limit: 100, probe: true, expected: "SELECT * FROM (SELECT id FROM t) AS _q LIMIT 101"}, + {name: "no probe wraps limit", sql: "SELECT id FROM t", limit: 100, probe: false, expected: "SELECT * FROM (SELECT id FROM t) AS _q LIMIT 100"}, + {name: "probe ignored when limit<=0", sql: "SELECT id FROM t", limit: 0, probe: true, expected: "SELECT id FROM t"}, + {name: "probe ignored when hasLimit", sql: "SELECT id FROM t LIMIT 5", limit: 100, probe: true, expected: "SELECT id FROM t LIMIT 5"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, applyLimit(tt.sql, tt.limit, tt.probe)) + }) + } +} + +func TestExecuteProbeTruncates(t *testing.T) { + pool, mock := newMock(t) + // probe limit=2 -> wraps LIMIT 3, 返回 3 行 -> truncated, 保留 2 行 + rows := sqlmock.NewRows([]string{"id"}).AddRow(1).AddRow(2).AddRow(3) + mock.ExpectQuery("SELECT \\* FROM \\(SELECT id FROM t\\) AS _q LIMIT 3").WillReturnRows(rows) + r, err := Execute(context.Background(), pool, "SELECT id FROM t", Options{Limit: 2, Probe: true}) + assert.NoError(t, err) + assert.True(t, r.Truncated) + assert.Equal(t, 2, len(r.Rows)) +} + +func TestExecuteProbeNoTruncate(t *testing.T) { + pool, mock := newMock(t) + // probe limit=2 -> wraps LIMIT 3, 返回 2 行(<=limit) -> 未截断 + rows := sqlmock.NewRows([]string{"id"}).AddRow(1).AddRow(2) + mock.ExpectQuery("SELECT \\* FROM \\(SELECT id FROM t\\) AS _q LIMIT 3").WillReturnRows(rows) + r, err := Execute(context.Background(), pool, "SELECT id FROM t", Options{Limit: 2, Probe: true}) + assert.NoError(t, err) + assert.False(t, r.Truncated) + assert.Equal(t, 2, len(r.Rows)) } From c3bd52d8adbf8399006e2f7e3037ea38bc383a6b Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 13:46:39 +0800 Subject: [PATCH 05/29] feat(config): add default_limit config field --- internal/config/config.go | 8 +++++--- internal/config/config_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 96b56e7..a5cbcb0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,11 +47,13 @@ type Datasource struct { type Config struct { Datasources map[string]Datasource `toml:"datasource"` DefaultDatasource string `toml:"default"` + DefaultLimit int `toml:"default_limit"` } type fileConfig struct { - Default string `toml:"default"` - Datasources map[string]fileDatasource `toml:"datasource"` + Default string `toml:"default"` + DefaultLimit int `toml:"default_limit"` + Datasources map[string]fileDatasource `toml:"datasource"` } type fileDatasource struct { @@ -89,7 +91,7 @@ func LoadFile(path string) (*Config, error) { if _, err := toml.DecodeFile(path, &fc); err != nil { return nil, err } - cfg := &Config{DefaultDatasource: fc.Default, Datasources: map[string]Datasource{}} + cfg := &Config{DefaultDatasource: fc.Default, DefaultLimit: fc.DefaultLimit, Datasources: map[string]Datasource{}} for name, fd := range fc.Datasources { cfg.Datasources[name] = fileToDatasource(fd) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7759197..5a20ca7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -179,3 +179,32 @@ func writeTmp(t *testing.T, content string) string { } return p } + +func TestDefaultLimitFromConfig(t *testing.T) { + toml := ` +default = "dev" +default_limit = 2500 + +[datasource.dev] +host = "127.0.0.1" +port = 3306 +` + tmp := t.TempDir() + "/config.toml" + assert.NoError(t, os.WriteFile(tmp, []byte(toml), 0644)) + cfg, err := LoadFile(tmp) + assert.NoError(t, err) + assert.Equal(t, 2500, cfg.DefaultLimit) +} + +func TestDefaultLimitZeroWhenUnset(t *testing.T) { + toml := ` +default = "dev" +[datasource.dev] +host = "127.0.0.1" +` + tmp := t.TempDir() + "/config.toml" + assert.NoError(t, os.WriteFile(tmp, []byte(toml), 0644)) + cfg, err := LoadFile(tmp) + assert.NoError(t, err) + assert.Equal(t, 0, cfg.DefaultLimit) +} From b768ec483709783eea6f700b3ddf5471be760f94 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 13:51:03 +0800 Subject: [PATCH 06/29] feat(format): ReadJSON envelope + jsonl format Add ReadJSON for read queries (SELECT): omits rows_affected and reports meta.truncated + meta.limit, consuming result.Result.Truncated from Task 1. Add jsonl format branch to Format: one JSON object per line via formatJSONL, with NULL rendered as native JSON null. SuccessJSON unchanged; existing TestJSONEnvelope still passes. --- internal/format/format.go | 42 ++++++++++++++++++++++++++++++++++ internal/format/format_test.go | 23 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/internal/format/format.go b/internal/format/format.go index feccfdb..3d98086 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -35,6 +35,27 @@ func SuccessJSON(r result.Result, meta map[string]any) string { return string(b) } +// ReadJSON renders the success envelope for a read query: omits rows_affected +// (always 0 for SELECT) and reports truncated/limit in meta. +func ReadJSON(r result.Result, limit int) string { + env := map[string]any{ + "success": true, + "data": map[string]any{ + "columns": r.Columns, + "rows": r.Rows, + }, + "meta": map[string]any{ + "truncated": r.Truncated, + "limit": limit, + }, + } + b, err := json.Marshal(env) + if err != nil { + return ErrorJSON("FORMAT_ERROR", "json marshal failed: "+err.Error()) + } + return string(b) +} + // ErrorJSON renders the error envelope. func ErrorJSON(code, message string) string { env := map[string]any{ @@ -57,6 +78,8 @@ func Format(r result.Result, format string) (string, error) { switch strings.ToLower(format) { case "json": return SuccessJSON(r, nil), nil + case "jsonl": + return formatJSONL(r) case "table": return formatTable(r), nil case "csv": @@ -112,3 +135,22 @@ func formatTable(r result.Result) string { tw.Render() return buf.String() } + +func formatJSONL(r result.Result) (string, error) { + var buf bytes.Buffer + for _, row := range r.Rows { + obj := make(map[string]any, len(row)) + for i, c := range row { + if i < len(r.Columns) { + obj[r.Columns[i]] = c + } + } + b, err := json.Marshal(obj) + if err != nil { + return "", err + } + buf.Write(b) + buf.WriteByte('\n') + } + return buf.String(), nil +} diff --git a/internal/format/format_test.go b/internal/format/format_test.go index 4e90e2c..0f5db95 100644 --- a/internal/format/format_test.go +++ b/internal/format/format_test.go @@ -79,3 +79,26 @@ func TestTSVCommaInValue(t *testing.T) { assert.Contains(t, out, "a,b") assert.NotContains(t, out, "a\tb") } + +func TestReadJSONOmitsRowsAffectedAndAddsMeta(t *testing.T) { + r := result.Result{Columns: []string{"id"}, Rows: [][]any{{1}}, Truncated: true} + out := ReadJSON(r, 1000) + var env struct { + Success bool `json:"success"` + Data struct{ Rows [][]any `json:"rows"` } `json:"data"` + RowsAffected *int `json:"rows_affected"` // pointer: nil when absent + Meta map[string]any `json:"meta"` + } + assert.NoError(t, json.Unmarshal([]byte(out), &env)) + assert.True(t, env.Success) + assert.Nil(t, env.RowsAffected) // SELECT omits rows_affected + assert.Equal(t, true, env.Meta["truncated"]) + assert.Equal(t, float64(1000), env.Meta["limit"]) +} + +func TestJSONL(t *testing.T) { + r := result.Result{Columns: []string{"id", "name"}, Rows: [][]any{{1, "a"}, {nil, "b"}}} + out, err := Format(r, "jsonl") + assert.NoError(t, err) + assert.Equal(t, `{"id":1,"name":"a"}`+"\n"+`{"id":null,"name":"b"}`+"\n", out) +} From cd19dc8e4037f1bdf27f70a1373917bc15925e84 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 13:57:59 +0800 Subject: [PATCH 07/29] feat(cli): --no-limit flag, default cap priority, read envelope routing --- internal/cli/commands.go | 58 ++++++++++++++++++++++++++++++-- internal/cli/commands_test.go | 63 +++++++++++++++++++++++++++++++++++ internal/cli/root.go | 40 ++++++++++++---------- 3 files changed, 141 insertions(+), 20 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index f411e44..90765d0 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strconv" "time" "github.com/AllenMuu/mysql-cli/internal/config" @@ -31,6 +32,9 @@ func (g *Globals) resolve() (config.Datasource, error) { if err != nil { return config.Datasource{}, err } + if cfg != nil { + g.DefaultLimit = cfg.DefaultLimit + } } over := config.Datasource{ Host: g.Host, Port: g.Port, User: g.User, Password: g.Password, Database: g.Database, @@ -55,6 +59,53 @@ func (g *Globals) opts() query.Options { return query.Options{Write: g.Write, DDL: g.DDL, Yes: g.Yes, Limit: g.Limit, Timeout: to} } +// defaultCap resolves the default row cap: config > env > built-in 1000. +func (g *Globals) defaultCap() int { + if g.DefaultLimit > 0 { + return g.DefaultLimit + } + if v, err := strconv.Atoi(os.Getenv("MYSQL_CLI_DEFAULT_LIMIT")); err == nil && v > 0 { + return v + } + return 1000 +} + +// resolveCap decides (limit, probe) for a read query: +// --no-limit -> (0, false) no cap +// --limit explicit -> (g.Limit, false) exact N, no probe +// otherwise -> (defaultCap, true) default cap with cap+1 probe +func (g *Globals) resolveCap(cmd *cobra.Command) (int, bool) { + if g.NoLimit { + return 0, false + } + if cmd.Flags().Changed("limit") { + return g.Limit, false + } + return g.defaultCap(), true +} + +// emitReadResult renders a read query result: json -> ReadJSON (slim envelope), +// jsonl -> line stream + stderr truncated notice, else -> Format. +func (g *Globals) emitReadResult(r result.Result, err error, limit int) { + if err != nil { + fmt.Fprintln(g.out, formatErr(err, g.Format)) + return + } + switch g.Format { + case "json": + fmt.Fprint(g.out, format.ReadJSON(r, limit)) + case "jsonl": + out, _ := format.Format(r, "jsonl") + fmt.Fprint(g.out, out) + if r.Truncated { + fmt.Fprintf(g.eout, "# truncated:true limit:%d\n", limit) + } + default: + out, _ := format.Format(r, g.Format) + fmt.Fprint(g.out, out) + } +} + func (g *Globals) emitResult(r result.Result, err error) { if err != nil { fmt.Fprintln(g.out, formatErr(err, g.Format)) @@ -96,11 +147,14 @@ func newQueryCmd(g *Globals) *cobra.Command { // writes through QueryContext, which the driver rejects. switch safety.Classify(sqlText) { case safety.CategoryRead, safety.CategoryUnknown: - r, err = query.Execute(ctx, pool, sqlText, g.opts()) + opts := g.opts() + opts.Limit, opts.Probe = g.resolveCap(cmd) + r, err = query.Execute(ctx, pool, sqlText, opts) + g.emitReadResult(r, err, opts.Limit) default: r, err = query.ExecuteWrite(ctx, pool, sqlText, g.opts()) + g.emitResult(r, err) } - g.emitResult(r, err) return err }, } diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index c373a9e..c804682 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -1,8 +1,11 @@ package cli import ( + "bytes" "testing" + "github.com/AllenMuu/mysql-cli/internal/result" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) @@ -66,3 +69,63 @@ func TestSchemaCommandsFailOnConnection(t *testing.T) { }) } } + +func TestDefaultCapFallbackTo1000(t *testing.T) { + g := &Globals{} + assert.Equal(t, 1000, g.defaultCap()) +} + +func TestDefaultCapFromConfig(t *testing.T) { + g := &Globals{DefaultLimit: 500} + assert.Equal(t, 500, g.defaultCap()) +} + +func TestDefaultCapFromEnv(t *testing.T) { + t.Setenv("MYSQL_CLI_DEFAULT_LIMIT", "200") + g := &Globals{} + assert.Equal(t, 200, g.defaultCap()) +} + +func TestResolveCapDefaultProbe(t *testing.T) { + g := &Globals{DefaultLimit: 500} + cmd := &cobra.Command{} + limit, probe := g.resolveCap(cmd) + assert.Equal(t, 500, limit) + assert.True(t, probe) +} + +func TestResolveCapNoLimitFlag(t *testing.T) { + g := &Globals{NoLimit: true} + cmd := &cobra.Command{} + limit, probe := g.resolveCap(cmd) + assert.Equal(t, 0, limit) + assert.False(t, probe) +} + +func TestResolveCapExplicitLimit(t *testing.T) { + g := &Globals{Limit: 50} + cmd := &cobra.Command{} + cmd.Flags().Int("limit", 0, "") + cmd.Flags().Set("limit", "50") + limit, probe := g.resolveCap(cmd) + assert.Equal(t, 50, limit) + assert.False(t, probe) +} + +func TestEmitReadJSONOmitsRowsAffected(t *testing.T) { + var out bytes.Buffer + g := &Globals{Format: "json", out: &out} + r := result.Result{Columns: []string{"id"}, Rows: [][]any{{1}}, Truncated: true} + g.emitReadResult(r, nil, 1000) + assert.Contains(t, out.String(), `"truncated":true`) + assert.NotContains(t, out.String(), "rows_affected") +} + +func TestEmitReadJSONLTruncatedStderr(t *testing.T) { + var out, eout bytes.Buffer + g := &Globals{Format: "jsonl", out: &out, eout: &eout} + r := result.Result{Columns: []string{"id"}, Rows: [][]any{{1}}, Truncated: true} + g.emitReadResult(r, nil, 1000) + assert.Contains(t, out.String(), `{"id":1}`) + assert.Contains(t, eout.String(), "# truncated:true limit:1000") +} diff --git a/internal/cli/root.go b/internal/cli/root.go index c727c23..9007ef8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -30,25 +30,28 @@ const ( // Globals carries parsed global flags shared by all subcommands. type Globals struct { - Datasource string - Format string - Write bool - DDL bool - Yes bool - Limit int - Timeout string - ConfigPath string - Host string - Port int - User string - Password string - Database string - out io.Writer + Datasource string + Format string + Write bool + DDL bool + Yes bool + Limit int + NoLimit bool + DefaultLimit int + Timeout string + ConfigPath string + Host string + Port int + User string + Password string + Database string + out io.Writer + eout io.Writer } // Run parses args and executes; returns the process exit code. func Run(args []string) int { - g := &Globals{Format: "json", out: os.Stdout} + g := &Globals{Format: "json", out: os.Stdout, eout: os.Stderr} root := newRootCmd(g) root.SetArgs(args) if err := root.Execute(); err != nil { @@ -68,8 +71,8 @@ func newRootCmd(g *Globals) *cobra.Command { SilenceUsage: true, Version: version, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if g.Format != "json" && g.Format != "table" && g.Format != "csv" && g.Format != "tsv" { - return fmt.Errorf("invalid format %q (want json|table|csv|tsv)", g.Format) + if g.Format != "json" && g.Format != "table" && g.Format != "csv" && g.Format != "tsv" && g.Format != "jsonl" { + return fmt.Errorf("invalid format %q (want json|table|csv|tsv|jsonl)", g.Format) } if _, err := time.ParseDuration(g.Timeout); err != nil { return fmt.Errorf("invalid timeout %q: %w", g.Timeout, err) @@ -79,11 +82,12 @@ func newRootCmd(g *Globals) *cobra.Command { } pf := root.PersistentFlags() pf.StringVarP(&g.Datasource, "datasource", "d", "", "named datasource from config") - pf.StringVarP(&g.Format, "format", "f", "json", "output format: json|table|csv|tsv") + pf.StringVarP(&g.Format, "format", "f", "json", "output format: json|table|csv|tsv|jsonl") pf.BoolVar(&g.Write, "write", false, "allow DML (INSERT/UPDATE/DELETE)") pf.BoolVar(&g.DDL, "ddl", false, "allow DDL (requires --write)") pf.BoolVar(&g.Yes, "yes", false, "confirm destructive operations") pf.IntVar(&g.Limit, "limit", 0, "row limit for SELECT queries") + pf.BoolVar(&g.NoLimit, "no-limit", false, "disable default row cap for SELECT (returns full result set)") pf.StringVar(&g.Timeout, "timeout", "30s", "query timeout") pf.StringVar(&g.ConfigPath, "config", defaultConfigPath(), "config file path") pf.StringVar(&g.Host, "host", "", "MySQL host") From d91c500812e82f06c0fe0939bc104bda07715eee Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 14:03:01 +0800 Subject: [PATCH 08/29] docs(skill): document default cap, --no-limit, jsonl; bump 1.1.0 --- skills/mysql-query/SKILL.md | 8 +++++++- skills/mysql-shared/SKILL.md | 18 +++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/skills/mysql-query/SKILL.md b/skills/mysql-query/SKILL.md index 58e0d15..184436b 100644 --- a/skills/mysql-query/SKILL.md +++ b/skills/mysql-query/SKILL.md @@ -1,6 +1,6 @@ --- name: mysql-query -version: 1.0.0 +version: 1.1.0 description: > Run SQL with mysql-cli: SELECT 查询、DML(INSERT/UPDATE/DELETE)、DDL(CREATE/ALTER/DROP)、 多语句原子事务。Use when user asks to run SQL, query data, insert/update/delete rows, @@ -132,3 +132,9 @@ mysql-cli txn \ `txn` for atomicity; never chain statements in `query`. / 多语句拆到 `txn`, `query` 内不要串语句。 - 错误修复、退出码、输出格式见 `mysql-shared`。/ For error recovery, exit codes, output formats, see `mysql-shared`. - 用 `mysql-cli query --help` 查看完整 flag。/ Run `mysql-cli query --help` for full flags. + +### 默认 cap 与截断 + +- SELECT 默认只返 1000 行;`meta.truncated=true` 表示被截断,需 `--no-limit` 或 `COUNT(*)` 评估全量后再决定。 +- 省 token:`--format jsonl`(紧凑)或 `--format csv`;避免 `--format table`(对 agent 极费 token)。 +- 确知要全表且可承受时才 `--no-limit`(实测 4.4 万行表裸跑 ≈900 万 token)。 diff --git a/skills/mysql-shared/SKILL.md b/skills/mysql-shared/SKILL.md index eb22668..0013a2a 100644 --- a/skills/mysql-shared/SKILL.md +++ b/skills/mysql-shared/SKILL.md @@ -1,6 +1,6 @@ --- name: mysql-shared -version: 1.0.0 +version: 1.1.0 description: > mysql-cli 共享规则:配置与数据源、全局 flag、安全模型、稳定退出码、错误自修复、输出格式。 使用 mysql-query 或 mysql-schema 技能前 MUST 先用 Read 加载本技能。也在用户询问 @@ -9,8 +9,8 @@ metadata: binary: mysql-cli config_file: ~/.config/mysql-cli/config.toml default_output: json - output_formats: json | table | csv | tsv - safety_model: read-only by default; --write (DML), --write --ddl (DDL), --yes (destructive) + output_formats: json | table | csv | tsv | jsonl + safety_model: read-only by default; --write (DML), --write --ddl (DDL), --yes (destructive); SELECT 默认 cap 1000 (--no-limit 关) license: MIT replaces: designcomputer/mysql_mcp_server --- @@ -130,6 +130,18 @@ non-JSON formats, errors render as `Error []: `. 用 `-f table`/`-f csv`/`-f tsv` 切换人类可读格式。非 JSON 格式下错误渲染为 `Error []: `。 +### 默认行数 cap / Default row cap + +SELECT 不带 LIMIT 时,mysql-cli 默认只返回前 1000 行并在 `meta.truncated=true` 标记(用 cap+1 探测,零额外查询)。 + +- `--limit N`:显式要 N 行,精确返回,不探测截断 +- `--no-limit`:关闭默认 cap,返回全表(危险,可能撑爆 context) +- `default_limit`(config.toml 顶层)/ `MYSQL_CLI_DEFAULT_LIMIT`(env):调默认 cap 值 +- 优先级:`--limit` > `--no-limit` > config > env > 1000 +- 见 `truncated:true` 时,要全量需 `--no-limit` 或先 `SELECT COUNT(*)` 评估 + +`--format jsonl`:每行一个 JSON 对象(`{"col":val,...}`),NULL 为 `null`,比 json 省 token;截断信息走 stderr。 + --- ## Error Handling / 错误自修复 From 7e8e5caba78e6b3a9b86f51d477dff2dc0079b3a Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 14:09:01 +0800 Subject: [PATCH 09/29] docs(changelog): breaking default cap + jsonl + --no-limit --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5920efa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## [Unreleased] + +### Breaking +- **SELECT 默认安全 cap 1000**:不带 LIMIT 的 SELECT 现在默认只返回 1000 行,`meta.truncated=true` 标记截断。需全表用 `--no-limit`;调默认值用 config `default_limit` 或 env `MYSQL_CLI_DEFAULT_LIMIT`;显式精确行数用 `--limit N`。动机:实测裸跑 4.4 万行表 = ~900 万 token(45 个 200K context 窗口),会当场撑爆 agent 会话。 +- **SELECT 的 JSON 信封省略 `rows_affected`**(对 SELECT 恒为 0);改用 `meta.truncated`/`meta.limit`。DML/DDL 信封不变。 + +### Added +- `--format jsonl`:每行一个 JSON 对象,比 json 紧凑,适合 agent。 +- `--no-limit` flag。 +- config `default_limit` / env `MYSQL_CLI_DEFAULT_LIMIT`。 From a7bea2170419f7e4477e5589549d6d885f8e56c3 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 14:28:15 +0800 Subject: [PATCH 10/29] fix(query,cli): cap priority order + SHOW/DESCRIBE skip + doc comments --- internal/cli/commands.go | 10 +++++----- internal/cli/commands_test.go | 10 ++++++++++ internal/format/format.go | 3 ++- internal/query/query.go | 3 ++- internal/query/query_test.go | 18 ++++++++++++++++++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 90765d0..cd5136d 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -71,16 +71,16 @@ func (g *Globals) defaultCap() int { } // resolveCap decides (limit, probe) for a read query: -// --no-limit -> (0, false) no cap // --limit explicit -> (g.Limit, false) exact N, no probe -// otherwise -> (defaultCap, true) default cap with cap+1 probe +// --no-limit -> (0, false) no cap +// otherwise -> (defaultCap, true) default cap with cap+1 probe func (g *Globals) resolveCap(cmd *cobra.Command) (int, bool) { - if g.NoLimit { - return 0, false - } if cmd.Flags().Changed("limit") { return g.Limit, false } + if g.NoLimit { + return 0, false + } return g.defaultCap(), true } diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index c804682..1a55ef7 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -112,6 +112,16 @@ func TestResolveCapExplicitLimit(t *testing.T) { assert.False(t, probe) } +func TestResolveCapLimitWinsOverNoLimit(t *testing.T) { + g := &Globals{NoLimit: true, Limit: 50} + cmd := &cobra.Command{} + cmd.Flags().Int("limit", 0, "") + cmd.Flags().Set("limit", "50") + limit, probe := g.resolveCap(cmd) + assert.Equal(t, 50, limit) + assert.False(t, probe) +} + func TestEmitReadJSONOmitsRowsAffected(t *testing.T) { var out bytes.Buffer g := &Globals{Format: "json", out: &out} diff --git a/internal/format/format.go b/internal/format/format.go index 3d98086..4ca357a 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -73,7 +73,8 @@ func ErrorJSON(code, message string) string { } // Format renders r in the requested format. csv/tsv encode NULL as empty -// string; table renders NULL as "NULL"; json is handled by SuccessJSON. +// string; table renders NULL as "NULL"; jsonl renders each row as a JSON +// object with NULL as native null; json is handled by SuccessJSON. func Format(r result.Result, format string) (string, error) { switch strings.ToLower(format) { case "json": diff --git a/internal/query/query.go b/internal/query/query.go index b6143ee..f2ec142 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -87,7 +87,7 @@ func Execute(ctx context.Context, pool *conn.Pool, sqlText string, opts Options) if err := rows.Err(); err != nil { return result.Empty(), fmt.Errorf("%w: %v", ErrSQL, err) } - if opts.Probe && opts.Limit > 0 && len(res.Rows) > opts.Limit { + if opts.Probe && opts.Limit > 0 && selectRe.MatchString(sqlText) && len(res.Rows) > opts.Limit { res.Truncated = true res.Rows = res.Rows[:opts.Limit] } @@ -104,6 +104,7 @@ func applyLimit(sqlText string, limit int, probe bool) string { if hasLimit(sqlText) { return sqlText } + // Strip a trailing semicolon so the wrapped subquery stays valid SQL. cleaned := strings.TrimRight(strings.TrimSpace(sqlText), ";") n := limit if probe { diff --git a/internal/query/query_test.go b/internal/query/query_test.go index e928c4f..991c49e 100644 --- a/internal/query/query_test.go +++ b/internal/query/query_test.go @@ -3,6 +3,7 @@ package query import ( "context" "database/sql" + "fmt" "testing" "time" @@ -159,3 +160,20 @@ func TestExecuteProbeNoTruncate(t *testing.T) { assert.False(t, r.Truncated) assert.Equal(t, 2, len(r.Rows)) } + +func TestExecuteProbeSkipsNonSelect(t *testing.T) { + // SHOW/DESCRIBE/EXPLAIN are CategoryRead but applyLimit refuses to wrap + // them (selectRe only matches SELECT|WITH). The post-scan truncation + // must also skip them so a large SHOW result is not silently truncated. + pool, mock := newMock(t) + rows := sqlmock.NewRows([]string{"Variable_name", "Value"}). + AddRow("a", fmt.Sprintf("v%d", 1)). + AddRow("b", fmt.Sprintf("v%d", 2)). + AddRow("c", fmt.Sprintf("v%d", 3)) + // ExpectQuery takes a regex; escape the space and treat the query literally. + mock.ExpectQuery("SHOW VARIABLES").WillReturnRows(rows) + r, err := Execute(context.Background(), pool, "SHOW VARIABLES", Options{Limit: 2, Probe: true}) + assert.NoError(t, err) + assert.False(t, r.Truncated) + assert.Equal(t, 3, len(r.Rows)) +} From 1f0f117c3c261554090a9c0c51c0ffc030d21308 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 15:15:12 +0800 Subject: [PATCH 11/29] docs(spec): project-level config loading design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 项目级 config:从 cwd 向上找 .config/mysql-cli/config.toml(与全局同构) - 覆盖式合并 + 信任清单机制(防恶意仓库/ENV 套取) - MYSQL_CLI_CONFIG env + config 子命令族(path/show/trust/init) - 优先级链:--config > env > 项目级(已信任) > 全局 - 分阶段实现建议(Phase 1-3) --- .../2026-07-24-project-level-config-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-24-project-level-config-design.md diff --git a/docs/superpowers/specs/2026-07-24-project-level-config-design.md b/docs/superpowers/specs/2026-07-24-project-level-config-design.md new file mode 100644 index 0000000..8716b89 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-project-level-config-design.md @@ -0,0 +1,181 @@ +# mysql-cli 项目级 config 加载 + +- 日期: 2026-07-24 +- 状态: 设计待审阅 +- 关联分支: 建议从 `feat/default-limit` 切出 `feat/project-config` + +## 背景 + +mysql-cli 当前只读 `~/.config/mysql-cli/config.toml`(`--config` flag 可覆盖为单文件),不支持环境变量指定路径,也没有项目级配置概念。这导致: + +- 不同项目无法各自维护 datasource(只能挤在全局文件里,命名冲突) +- 无法像 MCP 的 `.mcp.json` 那样让仓库自带连接配置 +- 没有 `MYSQL_CLI_CONFIG` 环境变量入口,部署/CI 场景不便 + +## 目标 + +1. **项目级 config**:从 cwd 向上查找 `.config/mysql-cli/config.toml`(与全局同构),与全局覆盖式合并 +2. **环境变量指定路径**:`MYSQL_CLI_CONFIG` 指定 config 文件 +3. **信任清单机制**:防恶意仓库(`.config/mysql-cli/config.toml` 指向攻击者 DB,或 `${ENV}` 套取本地环境变量密码) +4. **`config` 子命令族**:`path` / `show` / `trust` / `init`,支持 agent JSON 自省 + +## 非目标(YAGNI) + +- 字段级深合并(整体替换已满足覆盖式需求) +- `config untrust`(手动编辑纯文本清单即可) +- 交互式信任向导(agent 是首要调用方,非交互为主) +- 配置热重载、schema 校验 + +## 决策汇总(已与用户确认) + +| 决策点 | 选择 | +|---|---| +| 项目级发现方式 | 从 cwd 逐级向上找 `.config/mysql-cli/config.toml`,首个即停,到 home/fs root 为止 | +| 路径同构 | 项目级 `/.config/mysql-cli/config.toml` 与全局 `~/.config/mysql-cli/config.toml` 相对路径一致 | +| 合并语义 | 覆盖式:同名 datasource 整体替换(含 SSH 子表),不同名取并集;`default`/`default_limit` 项目级覆盖全局 | +| `default_limit=0` | 视为未设置,沿用全局/内置默认 cap;无限制仍用 `--no-limit` flag | +| 显式路径语义 | `--config` flag / `MYSQL_CLI_CONFIG` env 指定 = 只读该文件,跳过自动发现;flag > env | +| 安全信任 | 信任清单机制(`~/.config/mysql-cli/trusted`,纯文本);未信任静默回退全局,exit 0 | +| `${ENV}` 展开边界 | 信任是项目根级 all-or-nothing;未信任 = 项目级整体不加载,`${ENV}` 永不被触碰 | +| 信任清单格式 | 纯文本,每行一个规范化绝对路径(`filepath.EvalSymlinks`),权限 0600 | +| 辅助命令 | `config` 子命令族:`path` / `show` / `trust` / `init` | +| 未信任行为 | 静默回退全局,不报错,exit 0(对 agent 友好) | + +## 现状 + +- `internal/cli/commands.go:20` `defaultConfigPath()` 写死 `~/.config/mysql-cli/config.toml` +- `Globals.resolve()`(commands.go:28):文件存在 -> `config.LoadFile` 解析**单个**文件 -> `config.Resolve` 按 `flag > env > file > default` +- `Config{Datasources map[string]Datasource, Default, DefaultLimit}` +- 无环境变量路径入口,无项目级,无信任机制 + +## §1 架构与分层 + +新增 `internal/config/loader.go`,封装四件事:**路径发现 / 多层加载 / 覆盖式合并 / 信任清单**。cli 层只调一个入口。`config.go` 单文件解析逻辑不动,`Resolve` / `applyEnv` / `merge` 不动。 + +数据流: + +1. `ResolvePathChain(opts)` 确定文件链 +2. `MergeConfigs(chain)` 从低到高(全局 -> 项目级)覆盖式合成单个 `Config` +3. 现有 `Resolve` 做 datasource 字段解析(flag > `MYSQL_*` env > `Config` > default) + +改动面:`loader.go`(新增)、`cli/commands.go` 的 `Globals.resolve()`(改调 loader)、`cli` 新增 `config` 子命令族。 + +## §2 发现链 + +`ResolvePathChain(opts)` 确定文件链: + +- `--config` flag 设 -> 链 = `[该文件]`(单文件,跳过发现,**向后兼容**) +- 否则 `MYSQL_CLI_CONFIG` env 设 -> 链 = `[该文件]` +- 否则 -> 链 = `[项目级, 全局]`(项目级优先): + - **项目级**:从 cwd 逐级向上找 `.config/mysql-cli/config.toml`,首个即停,到 home 或 fs root 为止 + - **全局**:`~/.config/mysql-cli/config.toml`(现有) + +两者相对路径同构(`.config/mysql-cli/config.toml`),仅根不同(home vs 项目根)。 + +## §3 合并语义(覆盖式) + +`MergeConfigs(low, high *Config) *Config`(`low` = 全局,`high` = 项目级;`high` 为 nil 直接返回 `low`): + +| 字段 | 合并规则 | +|---|---| +| `Datasources[name]` | 两边都有 -> 整体替换为 `high`(含 SSH 子表随之替换,不做字段级 merge);仅一边有 -> 取有的一边 | +| `Default` | `high.Default != ""` 覆盖,否则 `low.Default` | +| `DefaultLimit` | `high.DefaultLimit != 0` 覆盖,否则 `low.DefaultLimit`;0 视为未设置,沿用全局/内置默认 cap。无限制仍走 `--no-limit` flag | + +**合并时机与信任**:信任判断在合并**之前**完成--未信任的项目级 config 不进入合并链。因此合并后的 `Config` 中所有 datasource 均来自已信任源(全局恒信任 + 项目级仅当已信任),`expandPassword`(`${ENV}` 展开)在选定 datasource 后做即可,无需区分来源,全部安全展开。合并的是带占位符的原始密码。 + +## §4 信任清单机制 + +**存储**:`~/.config/mysql-cli/trusted`,纯文本,每行一个**规范化的项目根绝对路径**(`filepath.EvalSymlinks` 后),防软链接欺骗。权限 `0600`。 + +**判断**:发现项目级 config(路径形如 `/.config/mysql-cli/config.toml`)-> 项目根 = ``(去掉 `.config/mysql-cli/config.toml` 后缀,**而非** config 文件的直接父目录 `.config/mysql-cli/`)-> 规范化后查清单: + +- **命中** -> 加载项目级,参与覆盖式合并;其 datasource 的 `${ENV}` 正常展开 +- **未命中** -> **整个项目级 config 不参与合并**,静默回退全局(**不报错、不阻塞 agent**);`config path` 标注未信任状态 + +**信任入口**: + +1. `mysql-cli config trust [dir]`(`dir` 默认 = 自动检测到的项目根;cwd 不在任何项目根下时回退到 cwd):规范化绝对路径追加到清单,**幂等**(已存在不重复)。这是主要入口。 +2. 交互终端(tty)且非 JSON 输出时,检测到未信任项目级 config 可提示 `[y/N]`--**可选增强**;agent 场景(非 tty / JSON 输出)一律跳过交互、静默回退,不破坏退出码契约。 + +**`${ENV}` 展开边界**:信任是**项目根级 all-or-nothing**。未信任 = 项目级整体不加载,自然无 `${ENV}` 可展开;已加载(已信任)的项目级 datasource 的 `${ENV}` 与全局一样正常展开。不做"半加载"。 + +## §5 config 子命令族 + +| 子命令 | 作用 | 输出 | +|---|---|---| +| `config path` `[-j]` | 显示生效文件链 + 信任状态 | 项目级路径(标注 `trusted` / `untrusted, skipped`)+ 全局路径 | +| `config show` `[-d name]` `[-j]` | 显示合并后最终 Config | 全部 datasource(密码脱敏)+ `default` + `default_limit`;`-d` 选单个 | +| `config trust [dir]` `[-j]` | 信任项目根(`dir` 默认检测到的项目根),追加清单 | 确认 + 规范化绝对路径;幂等 | +| `config init [--project\|--global] [--force]` | 生成模板 config.toml | `--project` 写项目根 `.config/mysql-cli/config.toml`,`--global` 写 `~/.config/mysql-cli/config.toml`;已存在则**不覆盖**,`--force` 覆盖 | + +**密码脱敏规则**(安全关键): + +- 明文密码 -> `***` +- `${ENV}` 占位符 -> **原样显示**(`${MYSQL_PASSWORD}`,不含明文,安全) + +`config show` 复用 loader,走完整发现 + 信任判断 + 合并,展示的是真实生效配置(与实际查询时一致),不只读单文件。 + +## §6 错误处理与退出码 + +沿用现有契约(2/3/4/5/6/7/8/9/10),**不新增退出码**,复用 10(config)。 + +| 场景 | 行为 | 退出码 | +|---|---|---| +| 任一 config.toml TOML 语法错 | 报错并指明哪个文件 | 10 | +| 信任清单读取失败 | 视为"无已信任目录",静默回退 | 0 | +| `config trust` 路径无效 / 规范化失败 | 报错 | 10 | +| `config trust` 写清单失败 | 报错 | 10 | +| 未找到项目级 config | 无项目级,只用全局(正常路径) | 0 | +| `${ENV}` 引用未设置环境变量(已信任加载后) | 现有 `ErrPlaceholderUnset` | 10 | +| `--config` 指定文件不存在 | 现状:`cfg=nil` 继续,env/default 兜底 | 0 | +| 未信任目录 | **静默回退全局,不报错** | 0 | + +## §7 完整优先级链 + +两层维度: + +- **文件选择层**:`--config` flag > `MYSQL_CLI_CONFIG` env > 项目级(已信任) > 全局 +- **字段层**(选定 datasource 后):flag overrides > `MYSQL_*` env > 文件 datasource > defaults + +## §8 测试策略(≥80%,sqlmock 无需 DB) + +`loader.go` 单测(核心): + +- `ResolvePathChain`:`--config` flag / `MYSQL_CLI_CONFIG` env / 向上发现项目级 / 到 home 边界 / 未找到 +- `MergeConfigs`:同名整体替换 / 不同名并集 / `Default` 覆盖 / `DefaultLimit=0` 视为未设置 / `high=nil` 直返 low / SSH 子表整体替换 +- 信任判断:命中 / 未命中 / `EvalSymlinks` 规范化 / 软链接防欺骗 +- `${ENV}` 展开:已信任正常展开 / 未信任不加载(占位符永不被触碰) +- `trusted` 文件:纯文本读写 / 幂等追加 / 权限 0600 + +cli 层: + +- `config path/show/trust/init` 子命令测试(沿用 `commands_test.go` 的 `Run` + 检查 stdout/exit 模式) +- **向后兼容**:无项目级 + 无 env 时,`Globals.resolve()` 行为与现状完全一致(现有测试不破) +- 边界:cwd 在项目根上层 / 多层向上找到项目根 / 项目级与全局同名 / 项目级 `default` 指向其独有 datasource / 未信任 + `${ENV}` + +无需集成测试(加载流程不依赖真 DB)。 + +**文档层**:skill 文档加一句"查询结果不符合预期时,先 `mysql-cli config path` 查信任状态",帮 agent 自省。 + +## §9 向后兼容 + +- 无项目级 + 无 env + 无 `--config`:行为与现状完全一致 +- `--config` 单文件语义不变(向后兼容) +- 现有 `~/.config/mysql-cli/config.toml` 用户无需改动 +- 新增能力对老用户透明(项目级需主动放置 + 信任) + +## §10 安全考量 + +- 恶意仓库放 `.config/mysql-cli/config.toml` 指向攻击者 DB 或用 `${ENV}` 套取本地环境变量密码 -> 信任清单拦截,未信任不加载、不展开 +- 信任清单按目录信任,`filepath.EvalSymlinks` 防软链接欺骗 +- 清单文件权限 0600 +- `config show` 密码脱敏,不泄露明文 + +## §11 实现阶段建议 + +可分阶段实现(降低单次 PR 风险): + +1. **Phase 1**:`loader.go`(发现 + 合并 + 信任判断)+ `Globals.resolve()` 接入 + 单测。不含子命令,行为完全兼容。 +2. **Phase 2**:`MYSQL_CLI_CONFIG` env + 信任清单读写 + `config trust` 子命令。 +3. **Phase 3**:`config path` / `show` / `init` 子命令 + skill 文档更新。 From f0713aaaf472b0e271bf7b4dbc8e8270d29e210b Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 15:23:58 +0800 Subject: [PATCH 12/29] docs(plan): project-level config implementation plan 12 TDD tasks across 3 phases: - Phase 1: loader.go core (DiscoverProject/MergeConfigs/Load) + Globals.resolve wiring (compat) - Phase 2: trust store + MYSQL_CLI_CONFIG env + 'config trust' - Phase 3: config path/show/init + Masked + skill docs - Task 12: full coverage gate (>=80%) --- .../plans/2026-07-24-project-level-config.md | 1594 +++++++++++++++++ 1 file changed, 1594 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-project-level-config.md diff --git a/docs/superpowers/plans/2026-07-24-project-level-config.md b/docs/superpowers/plans/2026-07-24-project-level-config.md new file mode 100644 index 0000000..e0051a4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-project-level-config.md @@ -0,0 +1,1594 @@ +# mysql-cli 项目级 config 加载 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 mysql-cli 支持项目级 config(从 cwd 向上找 `.config/mysql-cli/config.toml`)、`MYSQL_CLI_CONFIG` 环境变量、覆盖式合并、信任清单机制,以及 `config` 子命令族(path/show/trust/init)。 + +**Architecture:** 新增 `internal/config/loader.go` 封装"路径发现 + 多层加载 + 覆盖式合并 + 信任清单",cli 层只调 `config.Load(opts)` 一个入口。`config.go` 单文件解析逻辑不动,`Resolve`/`applyEnv`/`merge`/`expandPassword` 不动。信任判断前置到合并之前(未信任的项目级不进合并链),因此合并后 Config 全部来自已信任源,`${ENV}` 展开无需区分来源。 + +**Tech Stack:** Go 1.x, spf13/cobra, BurntSushi/toml, stretchr/testify, sqlmock(测试无需真 DB)。 + +## Global Constraints + +(每个 task 的要求隐式包含本节;值逐字来自 spec `docs/superpowers/specs/2026-07-24-project-level-config-design.md`) + +- 包严格单向依赖,`config` 是底层(`result` 之外无依赖)。新增 `loader.go` 在 `config` 包内,不引入新包。 +- 测试覆盖率 ≥80%,全部用 sqlmock / 临时文件,无需真 MySQL。 +- 退出码契约不变:复用 `ExitConfigError = 10`(`internal/cli/root.go:27`),**不新增退出码**。未信任静默回退 = exit 0。 +- 信任清单 `~/.config/mysql-cli/trusted`:纯文本,每行一个 `filepath.EvalSymlinks` 规范化的绝对路径,权限 `0600`。 +- 密码脱敏:明文 -> `***`;`${ENV}` 占位符原样显示。 +- `config.go` 的 `Resolve` / `applyEnv` / `merge` / `expandPassword` / `LoadFile` **不动**。 +- commit 用 conventional commits:`feat(config): ...` / `feat(cli): ...` / `docs(skill): ...`。 +- 项目级与全局相对路径同构:`.config/mysql-cli/config.toml`,仅根不同。 +- `default_limit = 0` 视为未设置(沿用全局/内置默认 cap);无限制仍走 `--no-limit`。 + +--- + +## File Structure + +| 文件 | 责任 | 动作 | +|---|---|---| +| `internal/config/loader.go` | 路径发现 / 多层加载 / 覆盖式合并 / 信任清单 / `Masked` 脱敏 | create | +| `internal/config/loader_test.go` | loader 单测(sqlmock 不需要,纯文件/env) | create | +| `internal/cli/commands.go` | `Globals.resolve()` 改调 `config.Load` | modify | +| `internal/cli/root.go` | 注册 `newConfigCmd(g)`;`Globals` 加 `ConfigExplicit`;`PersistentPreRunE` 设 `ConfigExplicit` | modify | +| `internal/cli/config_cmd.go` | `config` 子命令族(path/show/trust/init) | create | +| `internal/cli/config_cmd_test.go` | 子命令测试(`Run` + 退出码/stdout) | create | +| `skills/mysql-shared/SKILL.md` | 加项目级/信任说明 + `config path` 自省提示 | modify | + +**统一函数签名**(后续 task 引用,不得改名): + +```go +// internal/config/loader.go + +// PathEntry is one resolved config file in the chain (diagnostic view). +type PathEntry struct { + Path string // absolute config file path + Kind string // "explicit" | "project" | "global" + Trusted bool // true for explicit/global; project-only signal + Exists bool // file present on disk +} + +type LoadOpts struct { + ConfigFlag string // --config value ("" if not explicitly set) + EnvConfig string // MYSQL_CLI_CONFIG value ("" if unset) + Cwd string // project discovery start dir + Home string // home dir: global config + trust store + IsTrusted func(projectRoot string) bool // injectable; nil -> use trust file at opts.Home +} + +// DiscoverProject walks up from start looking for .config/mysql-cli/config.toml. +// Returns (projectRoot, configPath, found). projectRoot strips the +// .config/mysql-cli/config.toml suffix. Stops at home or filesystem root. +func DiscoverProject(start, home string) (root, configPath string, found bool) + +// MergeConfigs overlays high onto low (覆盖式). high==nil returns low (nil-safe). +func MergeConfigs(low, high *Config) *Config + +// ResolvePathChain returns the diagnostic view of ALL discovered entries +// (including an untrusted project entry, marked Trusted=false), ordered low->high. +func ResolvePathChain(opts LoadOpts) ([]PathEntry, error) + +// Load resolves chain, loads trusted/explicit/global entries, merges -> Config. +// Returns (mergedConfig, entries, err). mergedConfig is nil if no file loaded. +func Load(opts LoadOpts) (*Config, []PathEntry, error) + +// Trust store +func TrustFilePath(home string) string // /.config/mysql-cli/trusted +func IsTrusted(home, projectRoot string) bool // EvalSymlinks-normalized lookup +func AddTrust(home, projectRoot string) error // idempotent append, 0600 +func ReadTrusted(home string) ([]string, error) // parse plaintext lines + +// Masked returns a copy of ds with plaintext password -> "***". +// "${ENV}" placeholders (match ^\$\{...\}$) are left as-is. +func Masked(ds Datasource) Datasource +``` + +--- + +## Phase 1 — loader 核心 + 接入(行为完全兼容) + +### Task 1: `DiscoverProject` — 向上发现项目级 config + +**Files:** +- Create: `internal/config/loader.go` +- Test: `internal/config/loader_test.go` + +**Interfaces:** +- Consumes: `config` 包现有类型(无) +- Produces: `DiscoverProject(start, home string) (root, configPath string, found bool)` + +- [ ] **Step 1: Write the failing test** + +```go +// internal/config/loader_test.go +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +// helper: build a fake project tree under a temp home. +func makeProjectTree(t *testing.T, home string, relPath string) { + t.Helper() + p := filepath.Join(home, relPath) + assert.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + assert.NoError(t, os.WriteFile(p, []byte("# stub"), 0o600)) +} + +func TestDiscoverProject_FoundAtCwd(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, "proj") + makeProjectTree(t, root, ".config/mysql-cli/config.toml") + gotRoot, gotPath, found := DiscoverProject(root, home) + assert.True(t, found) + assert.Equal(t, root, gotRoot) + assert.Equal(t, filepath.Join(root, ".config/mysql-cli/config.toml"), gotPath) +} + +func TestDiscoverProject_FoundInAncestor(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, "proj") + makeProjectTree(t, root, ".config/mysql-cli/config.toml") + // cwd is a subdir of root + cwd := filepath.Join(root, "a", "b") + assert.NoError(t, os.MkdirAll(cwd, 0o755)) + gotRoot, _, found := DiscoverProject(cwd, home) + assert.True(t, found) + assert.Equal(t, root, gotRoot) +} + +func TestDiscoverProject_StopsAtHome(t *testing.T) { + home := t.TempDir() + cwd := filepath.Join(home, "proj", "sub") + assert.NoError(t, os.MkdirAll(cwd, 0o755)) + _, _, found := DiscoverProject(cwd, home) + assert.False(t, found) // nothing above cwd until home (home itself is boundary, not searched as project) +} + +func TestDiscoverProject_NotFound(t *testing.T) { + home := t.TempDir() + cwd := filepath.Join(home, "x") + assert.NoError(t, os.MkdirAll(cwd, 0o755)) + _, _, found := DiscoverProject(cwd, home) + assert.False(t, found) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestDiscoverProject -v` +Expected: FAIL with "undefined: DiscoverProject". + +- [ ] **Step 3: Write minimal implementation** + +```go +// internal/config/loader.go +package config + +import ( + "os" + "path/filepath" +) + +// relConfigPath is the shared relative path for both global and project configs. +const relConfigPath = ".config/mysql-cli/config.toml" + +// DiscoverProject walks up from start looking for .config/mysql-cli/config.toml. +// Returns (projectRoot, configPath, found). projectRoot strips the relConfigPath +// suffix (it is the dir containing .config/, NOT .config/mysql-cli/ itself). +// Stops when reaching home or the filesystem root. +func DiscoverProject(start, home string) (root, configPath string, found bool) { + dir := start + for { + candidate := filepath.Join(dir, relConfigPath) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return dir, candidate, true + } + // stop at home boundary (do not search home itself as a "project") + if dir == home || dir == filepath.Dir(dir) { + return "", "", false + } + dir = filepath.Dir(dir) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/config/ -run TestDiscoverProject -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/loader.go internal/config/loader_test.go +git commit -m "feat(config): add DiscoverProject for project-level config discovery" +``` + +--- + +### Task 2: `MergeConfigs` — 覆盖式合并 + +**Files:** +- Modify: `internal/config/loader.go` +- Test: `internal/config/loader_test.go` + +**Interfaces:** +- Consumes: `config.Config`, `config.Datasource`(现有) +- Produces: `MergeConfigs(low, high *Config) *Config` + +- [ ] **Step 1: Write the failing test** + +```go +// append to internal/config/loader_test.go + +func TestMergeConfigs_NilHigh(t *testing.T) { + low := &Config{DefaultDatasource: "g", Datasources: map[string]Datasource{"g": {Host: "h"}}} + out := MergeConfigs(low, nil) + assert.Same(t, low, out) // nil-safe: returns low directly +} + +func TestMergeConfigs_SameNameReplaced(t *testing.T) { + low := &Config{Datasources: map[string]Datasource{"prod": {Host: "global-prod", User: "guser"}}} + high := &Config{Datasources: map[string]Datasource{"prod": {Host: "proj-prod"}}} + out := MergeConfigs(low, high) + // whole-replace: high.prod wins entirely, low.prod.User is gone + assert.Equal(t, "proj-prod", out.Datasources["prod"].Host) + assert.Equal(t, "", out.Datasources["prod"].User) +} + +func TestMergeConfigs_UnionOfNames(t *testing.T) { + low := &Config{Datasources: map[string]Datasource{"a": {Host: "ga"}}} + high := &Config{Datasources: map[string]Datasource{"b": {Host: "pb"}}} + out := MergeConfigs(low, high) + assert.Len(t, out.Datasources, 2) + assert.Equal(t, "ga", out.Datasources["a"].Host) + assert.Equal(t, "pb", out.Datasources["b"].Host) +} + +func TestMergeConfigs_SSHReplacedWholesale(t *testing.T) { + low := &Config{Datasources: map[string]Datasource{"d": {SSH: &SSHConfig{Host: "gh"}}}} + high := &Config{Datasources: map[string]Datasource{"d": {SSH: &SSHConfig{Host: "ph"}}}} + out := MergeConfigs(low, high) + assert.Equal(t, "ph", out.Datasources["d"].SSH.Host) +} + +func TestMergeConfigs_DefaultOverride(t *testing.T) { + low := &Config{DefaultDatasource: "g"} + high := &Config{DefaultDatasource: "p"} + assert.Equal(t, "p", MergeConfigs(low, high).DefaultDatasource) + // high.Default empty -> keep low + high2 := &Config{Datasources: map[string]Datasource{}} + assert.Equal(t, "g", MergeConfigs(low, high2).DefaultDatasource) +} + +func TestMergeConfigs_DefaultLimitZeroIsUnset(t *testing.T) { + low := &Config{DefaultLimit: 2500} + highZero := &Config{DefaultLimit: 0} + assert.Equal(t, 2500, MergeConfigs(low, highZero).DefaultLimit) // 0 = unset -> keep low + highSet := &Config{DefaultLimit: 500} + assert.Equal(t, 500, MergeConfigs(low, highSet).DefaultLimit) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestMergeConfigs -v` +Expected: FAIL with "undefined: MergeConfigs". + +- [ ] **Step 3: Write minimal implementation** + +```go +// append to internal/config/loader.go + +// MergeConfigs overlays high onto low using覆盖式 (override) semantics: +// same-name datasource is replaced wholesale (including SSH subtable), +// distinct names are unioned, Default/DefaultLimit override when non-zero/non-empty. +// high==nil returns low unchanged (nil-safe). +func MergeConfigs(low, high *Config) *Config { + if high == nil { + return low + } + if low == nil { + low = &Config{Datasources: map[string]Datasource{}} + } + out := &Config{ + DefaultDatasource: low.DefaultDatasource, + DefaultLimit: low.DefaultLimit, + Datasources: map[string]Datasource{}, + } + for k, v := range low.Datasources { + out.Datasources[k] = v + } + for k, v := range high.Datasources { + out.Datasources[k] = v // whole-replace (shallow copy of Datasource value is fine: it's a value type, SSH ptr shared with high) + } + if high.DefaultDatasource != "" { + out.DefaultDatasource = high.DefaultDatasource + } + if high.DefaultLimit != 0 { + out.DefaultLimit = high.DefaultLimit + } + return out +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/config/ -run TestMergeConfigs -v` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/loader.go internal/config/loader_test.go +git commit -m "feat(config): add MergeConfigs with override semantics" +``` + +--- + +### Task 3: `ResolvePathChain` + `Load` — 总入口(信任判断先注入 stub) + +> 本 task 信任判断用注入的 `IsTrusted` stub(测试覆盖);真信任清单实现在 Task 5,Task 6 把默认实现接上。这样 Phase 1 可独立交付、行为兼容(信任默认 false 时项目级不加载,等价于"无项目级")。 + +**Files:** +- Modify: `internal/config/loader.go` +- Test: `internal/config/loader_test.go` + +**Interfaces:** +- Consumes: `DiscoverProject`(Task 1), `MergeConfigs`(Task 2), `LoadFile`(现有) +- Produces: `PathEntry`, `LoadOpts`, `ResolvePathChain(opts) ([]PathEntry, error)`, `Load(opts) (*Config, []PathEntry, error)` + +- [ ] **Step 1: Write the failing test** + +```go +// append to internal/config/loader_test.go + +func writeCfgAt(t *testing.T, path, content string) { + t.Helper() + assert.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + assert.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +func TestLoad_ConfigFlagSingleFile(t *testing.T) { + home := t.TempDir() + explicit := filepath.Join(home, "x.toml") + writeCfgAt(t, explicit, `default = "a" +[datasource.a] +host = "ha" +`) + cfg, entries, err := Load(LoadOpts{ConfigFlag: explicit, Home: home, Cwd: home}) + assert.NoError(t, err) + assert.Equal(t, "ha", cfg.Datasources["a"].Host) + assert.Len(t, entries, 1) + assert.Equal(t, "explicit", entries[0].Kind) + assert.True(t, entries[0].Trusted) +} + +func TestLoad_EnvConfigSingleFile(t *testing.T) { + home := t.TempDir() + env := filepath.Join(home, "e.toml") + writeCfgAt(t, env, `[datasource.b] +host = "hb" +`) + cfg, entries, err := Load(LoadOpts{EnvConfig: env, Home: home, Cwd: home}) + assert.NoError(t, err) + assert.Equal(t, "hb", cfg.Datasources["b"].Host) + assert.Equal(t, "explicit", entries[0].Kind) // env path treated as explicit single-file +} + +func TestLoad_ProjectTrustedMergedOverGlobal(t *testing.T) { + home := t.TempDir() + globalPath := filepath.Join(home, relConfigPath) + writeCfgAt(t, globalPath, `default = "g" +[datasource.g] +host = "gh" +[datasource.shared] +host = "sh" +`) + projRoot := filepath.Join(home, "proj") + projPath := filepath.Join(projRoot, relConfigPath) + writeCfgAt(t, projPath, `default = "p" +[datasource.p] +host = "ph" +[datasource.shared] +host = "projsh" +`) + cfg, entries, err := Load(LoadOpts{ + Cwd: projRoot, Home: home, + IsTrusted: func(string) bool { return true }, // trusted + }) + assert.NoError(t, err) + // union: g (global-only) + p (project-only) + shared (project wins) + assert.Equal(t, "gh", cfg.Datasources["g"].Host) + assert.Equal(t, "ph", cfg.Datasources["p"].Host) + assert.Equal(t, "projsh", cfg.Datasources["shared"].Host) + assert.Equal(t, "p", cfg.DefaultDatasource) + // entries: project + global, both trusted + assert.Len(t, entries, 2) +} + +func TestLoad_ProjectUntrustedFallsBackToGlobal(t *testing.T) { + home := t.TempDir() + globalPath := filepath.Join(home, relConfigPath) + writeCfgAt(t, globalPath, `[datasource.g] +host = "gh" +`) + projRoot := filepath.Join(home, "proj") + writeCfgAt(t, filepath.Join(projRoot, relConfigPath), `[datasource.p] +host = "ph" +`) + cfg, entries, err := Load(LoadOpts{ + Cwd: projRoot, Home: home, + IsTrusted: func(string) bool { return false }, // untrusted + }) + assert.NoError(t, err) // silent fallback, no error + assert.Equal(t, "gh", cfg.Datasources["g"].Host) + assert.NotContains(t, cfg.Datasources, "p") // project NOT loaded + // entries still show project entry (diagnostic), marked untrusted + var projEntry *PathEntry + for i := range entries { + if entries[i].Kind == "project" { + projEntry = &entries[i] + } + } + if assert.NotNil(t, projEntry) { + assert.False(t, projEntry.Trusted) + } +} + +func TestLoad_NoConfigReturnsNil(t *testing.T) { + home := t.TempDir() + cfg, _, err := Load(LoadOpts{Cwd: home, Home: home}) + assert.NoError(t, err) + assert.Nil(t, cfg) +} + +func TestLoad_TomlSyntaxError(t *testing.T) { + home := t.TempDir() + bad := filepath.Join(home, "bad.toml") + writeCfgAt(t, bad, `default = "unclosed`) + _, _, err := Load(LoadOpts{ConfigFlag: bad, Home: home, Cwd: home}) + assert.Error(t, err) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestLoad -v` +Expected: FAIL with "undefined: Load / LoadOpts / PathEntry". + +- [ ] **Step 3: Write minimal implementation** + +```go +// append to internal/config/loader.go + +// PathEntry is one resolved config file in the chain (diagnostic view). +type PathEntry struct { + Path string // absolute config file path + Kind string // "explicit" | "project" | "global" + Trusted bool // true for explicit/global; project-only signal + Exists bool // file present on disk +} + +// LoadOpts controls path resolution, project discovery, and trust checks. +type LoadOpts struct { + ConfigFlag string // --config value ("" if not explicitly set) + EnvConfig string // MYSQL_CLI_CONFIG value ("" if unset) + Cwd string // project discovery start dir + Home string // home dir: global config + trust store + IsTrusted func(projectRoot string) bool // injectable; nil -> always false (Phase 1) +} + +// globalConfigPath returns /.config/mysql-cli/config.toml. +func globalConfigPath(home string) string { return filepath.Join(home, relConfigPath) } + +// ResolvePathChain returns the diagnostic view of all discovered entries +// (including an untrusted project entry marked Trusted=false), ordered low->high. +func ResolvePathChain(opts LoadOpts) ([]PathEntry, error) { + var entries []PathEntry + // explicit single-file (flag or env) short-circuits discovery + if opts.ConfigFlag != "" || opts.EnvConfig != "" { + p := opts.ConfigFlag + if p == "" { + p = opts.EnvConfig + } + _, err := os.Stat(p) + entries = []PathEntry{{Path: p, Kind: "explicit", Trusted: true, Exists: err == nil}} + return entries, nil + } + // project (if found), then global + if root, p, found := DiscoverProject(opts.Cwd, opts.Home); found { + trusted := false + if opts.IsTrusted != nil { + trusted = opts.IsTrusted(root) + } + entries = append(entries, PathEntry{Path: p, Kind: "project", Trusted: trusted, Exists: true}) + } + gp := globalConfigPath(opts.Home) + _, err := os.Stat(gp) + entries = append(entries, PathEntry{Path: gp, Kind: "global", Trusted: true, Exists: err == nil}) + return entries, nil +} + +// Load resolves the chain, loads trusted/explicit/global entries, merges -> Config. +// Returns (mergedConfig, entries, err). mergedConfig is nil if no file was loaded. +// Trust is enforced at merge time: an untrusted project entry is NOT loaded, +// so the merged Config contains only trusted sources. +func Load(opts LoadOpts) (*Config, []PathEntry, error) { + entries, err := ResolvePathChain(opts) + if err != nil { + return nil, entries, err + } + var merged *Config + // load low->high: global first, then project (if trusted). explicit is single. + for _, e := range entries { + if !e.Exists { + continue + } + if e.Kind == "project" && !e.Trusted { + continue // untrusted project: skip load entirely + } + cfg, err := LoadFile(e.Path) + if err != nil { + return nil, entries, err + } + merged = MergeConfigs(merged, cfg) + } + return merged, entries, nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/config/ -run "TestLoad|TestDiscoverProject|TestMergeConfigs" -v` +Expected: PASS (all loader tests green). + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/loader.go internal/config/loader_test.go +git commit -m "feat(config): add Load/ResolvePathChain with trust-gated merge" +``` + +--- + +### Task 4: 接入 `Globals.resolve()` + `ConfigExplicit`(行为兼容) + +**Files:** +- Modify: `internal/cli/root.go`(`Globals` 加字段 + `PersistentPreRunE` 设 `ConfigExplicit` + 注册 `newConfigCmd` 占位) +- Modify: `internal/cli/commands.go`(`resolve()` 改调 `config.Load`) +- Test: `internal/cli/commands_test.go`(确认现有测试仍通过) + +**Interfaces:** +- Consumes: `config.Load`, `config.LoadOpts`(Task 3) +- Produces: `Globals.ConfigExplicit bool`;`resolve()` 用 `config.Load` + +> 注:`newConfigCmd` 在 Task 7+ 才有实参;本 task 先在 `root.go` 注册一个**空壳** `newConfigCmd(g)`(返回 `&cobra.Command{Use:"config"}`),避免编译错。Task 7 起逐步填充子命令。 + +- [ ] **Step 1: Write the failing test (兼容性回归)** + +```go +// append to internal/cli/commands_test.go + +// Behavioral compat: no project + no env + no explicit --config behaves as today. +func TestResolveCompatNoConfig(t *testing.T) { + // HOME isolated -> no global config -> env/default fallback. + t.Setenv("HOME", t.TempDir()) + code := Run([]string{"query", "SELECT 1", "--host", "127.0.0.1", "--port", "1"}) + assert.Equal(t, ExitConnFailed, code) // reached connection stage (config ok, conn fails) +} + +// --config single-file still works and is the only source. +func TestResolveCompatExplicitConfigFlag(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "c.toml") + os.WriteFile(cfg, []byte(`[datasource.x] +host = "h" +`), 0o600) + t.Setenv("HOME", t.TempDir()) + code := Run([]string{"query", "SELECT 1", "-d", "nonexistent", "--config", cfg}) + assert.Equal(t, ExitConfigError, code) // unknown datasource -> config error (file loaded, name missing) +} +``` + +- [ ] **Step 2: Run test to verify it fails (or confirm baseline)** + +Run: `go test ./internal/cli/ -run "TestResolveCompat" -v` +Expected: FAIL (resolve still uses old single-file path; new tests may pass by luck but `resolve()` not yet calling Load). Confirm at minimum that the suite compiles. + +- [ ] **Step 3: Modify `root.go` — add `ConfigExplicit` + register placeholder `config` cmd** + +```go +// internal/cli/root.go — edit Globals struct (add field after ConfigPath): + ConfigPath string + ConfigExplicit bool // true when --config was explicitly set on the command line + +// inside newRootCmd, edit PersistentPreRunE to set ConfigExplicit: + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + g.ConfigExplicit = cmd.Flags().Changed("config") + if g.Format != "json" && g.Format != "table" && g.Format != "csv" && g.Format != "tsv" && g.Format != "jsonl" { + return fmt.Errorf("invalid format %q (want json|table|csv|tsv|jsonl)", g.Format) + } + if _, err := time.ParseDuration(g.Timeout); err != nil { + return fmt.Errorf("invalid timeout %q: %w", g.Timeout, err) + } + return nil + }, + +// in root.AddCommand(...), add newConfigCmd(g): + newConfigCmd(g), + newInitCmd(), +``` + +```go +// internal/cli/config_cmd.go — placeholder (filled in Task 7+) +package cli + +import "github.com/spf13/cobra" + +func newConfigCmd(g *Globals) *cobra.Command { + return &cobra.Command{ + Use: "config", + Short: "Manage config: project-level discovery, trust, and inspection", + } +} +``` + +- [ ] **Step 4: Modify `commands.go` — `resolve()` uses `config.Load`** + +```go +// internal/cli/commands.go — replace the body of (g *Globals) resolve(): +func (g *Globals) resolve() (config.Datasource, error) { + cwd, _ := os.Getwd() + home, err := os.UserHomeDir() + if err != nil { + home = "" + } + cfgFlag := "" + if g.ConfigExplicit { + cfgFlag = g.ConfigPath + } + merged, _, err := config.Load(config.LoadOpts{ + ConfigFlag: cfgFlag, + EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"), + Cwd: cwd, + Home: home, + // IsTrusted left nil in Phase 1 -> project never loaded (compat). + // Task 6 wires the real trust store here. + }) + if err != nil { + return config.Datasource{}, err + } + if merged != nil { + g.DefaultLimit = merged.DefaultLimit + } + over := config.Datasource{ + Host: g.Host, Port: g.Port, User: g.User, Password: g.Password, Database: g.Database, + } + return config.Resolve(merged, g.Datasource, over) +} +``` + +(`defaultConfigPath()` 可保留未用,或删除;为最小改动保留它作为 `--config` flag 的 default value 字符串来源。) + +- [ ] **Step 5: Run tests — full cli suite must stay green** + +Run: `go test ./internal/cli/ -v && go test ./internal/config/ -v` +Expected: PASS (existing tests unaffected; new compat tests pass). Coverage check: `go test -cover ./internal/config/ ./internal/cli/`. + +- [ ] **Step 6: Commit** + +```bash +git add internal/cli/root.go internal/cli/commands.go internal/cli/config_cmd.go internal/cli/commands_test.go +git commit -m "feat(cli): wire Globals.resolve to config.Load (compat preserved)" +``` + +--- + +## Phase 2 — 环境变量 + 信任清单 + `config trust` + +### Task 5: 信任清单读写(`TrustFilePath`/`IsTrusted`/`AddTrust`/`ReadTrusted`) + +**Files:** +- Modify: `internal/config/loader.go` +- Test: `internal/config/loader_test.go` + +**Interfaces:** +- Consumes: `filepath.EvalSymlinks`(stdlib) +- Produces: `TrustFilePath`, `IsTrusted`, `AddTrust`, `ReadTrusted` + +- [ ] **Step 1: Write the failing test** + +```go +// append to internal/config/loader_test.go + +func TestTrustFilePath(t *testing.T) { + assert.Equal(t, filepath.Join("H", ".config", "mysql-cli", "trusted"), TrustFilePath("H")) +} + +func TestAddTrust_Idempotent(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, "proj") + assert.NoError(t, os.MkdirAll(root, 0o755)) + assert.NoError(t, AddTrust(home, root)) + assert.NoError(t, AddTrust(home, root)) // duplicate, no error, single line + list, err := ReadTrusted(home) + assert.NoError(t, err) + assert.Equal(t, []string{root}, list) +} + +func TestIsTrusted_HitAndMiss(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, "proj") + os.MkdirAll(root, 0o755) + assert.False(t, IsTrusted(home, root)) + assert.NoError(t, AddTrust(home, root)) + assert.True(t, IsTrusted(home, root)) +} + +func TestIsTrusted_SymlinkNormalized(t *testing.T) { + home := t.TempDir() + real := filepath.Join(home, "real") + os.MkdirAll(real, 0o755) + link := filepath.Join(home, "link") + os.Symlink(real, link) + assert.NoError(t, AddTrust(home, link)) // add via symlink path + assert.True(t, IsTrusted(home, real)) // resolves to real -> trusted +} + +func TestReadTrusted_NoFileReturnsEmpty(t *testing.T) { + home := t.TempDir() + list, err := ReadTrusted(home) + assert.NoError(t, err) + assert.Empty(t, list) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run "TestTrustFilePath|TestAddTrust|TestIsTrusted|TestReadTrusted" -v` +Expected: FAIL with "undefined: TrustFilePath" etc. + +- [ ] **Step 3: Write minimal implementation** + +```go +// append to internal/config/loader.go + +import "sort" // add to existing import block + +// TrustFilePath returns /.config/mysql-cli/trusted. +func TrustFilePath(home string) string { + return filepath.Join(home, relConfigPath[:len(relConfigPath)-len("config.toml")]+"trusted") +} +// (equivalent to filepath.Join(home, ".config", "mysql-cli", "trusted")) + +// ReadTrusted parses the plaintext trust file (one normalized path per line). +// Missing file -> empty list, no error. +func ReadTrusted(home string) ([]string, error) { + b, err := os.ReadFile(TrustFilePath(home)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []string + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line != "" { + out = append(out, line) + } + } + return out, nil +} + +// normalizePath resolves symlinks to a canonical absolute path. +func normalizePath(p string) string { + if r, err := filepath.EvalSymlinks(p); err == nil { + return r + } + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p +} + +// IsTrusted reports whether projectRoot (symlink-normalized) is in the trust file. +func IsTrusted(home, projectRoot string) bool { + target := normalizePath(projectRoot) + list, err := ReadTrusted(home) + if err != nil { + return false // unreadable trust store -> treat as none, silent fallback + } + for _, e := range list { + if e == target { + return true + } + } + return false +} + +// AddTrust appends projectRoot (normalized) to the trust file, idempotently. +// Creates the parent dir and file with 0600 if absent. +func AddTrust(home, projectRoot string) error { + target := normalizePath(projectRoot) + list, _ := ReadTrusted(home) + for _, e := range list { + if e == target { + return nil // already trusted + } + } + list = append(list, target) + sort.Strings(list) + var b strings.Builder + for i, e := range list { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(e) + } + b.WriteByte('\n') + tf := TrustFilePath(home) + if err := os.MkdirAll(filepath.Dir(tf), 0o700); err != nil { + return err + } + return os.WriteFile(tf, []byte(b.String()), 0o600) +} +``` + +> Add `import "sort"` and `import "strings"` to the import block. (`os`/`filepath` already imported.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/config/ -run "TestTrustFilePath|TestAddTrust|TestIsTrusted|TestReadTrusted" -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/loader.go internal/config/loader_test.go +git commit -m "feat(config): add trust store (plaintext, symlink-normalized, 0600)" +``` + +--- + +### Task 6: `Load` 接真信任清单 + `MYSQL_CLI_CONFIG` env 路由 + +**Files:** +- Modify: `internal/config/loader.go`(`Load` 默认 `IsTrusted` 用 trust file) +- Modify: `internal/cli/commands.go`(`resolve` 传真 `IsTrusted`) +- Test: `internal/config/loader_test.go` + +**Interfaces:** +- Consumes: `IsTrusted`(Task 5) +- Produces: `Load` 默认信任行为(无注入时用 trust file) + +- [ ] **Step 1: Write the failing test** + +```go +// append to internal/config/loader_test.go + +// Default IsTrusted (nil) uses the real trust file at Home. +func TestLoad_DefaultIsTrustedUsesTrustFile(t *testing.T) { + home := t.TempDir() + globalPath := filepath.Join(home, relConfigPath) + writeCfgAt(t, globalPath, `[datasource.g] +host = "gh" +`) + projRoot := filepath.Join(home, "proj") + writeCfgAt(t, filepath.Join(projRoot, relConfigPath), `[datasource.p] +host = "ph" +`) + // not trusted yet -> project skipped + cfg, _, err := Load(LoadOpts{Cwd: projRoot, Home: home}) // IsTrusted nil + assert.NoError(t, err) + assert.NotContains(t, cfg.Datasources, "p") + + // trust it -> project loaded + assert.NoError(t, AddTrust(home, projRoot)) + cfg2, _, err := Load(LoadOpts{Cwd: projRoot, Home: home}) + assert.NoError(t, err) + assert.Equal(t, "ph", cfg2.Datasources["p"].Host) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestLoad_DefaultIsTrustedUsesTrustFile -v` +Expected: FAIL (Phase 1 default IsTrusted always false). + +- [ ] **Step 3: Wire the default in `Load`** + +```go +// internal/config/loader.go — in Load(), before ResolvePathChain: +func Load(opts LoadOpts) (*Config, []PathEntry, error) { + isTrusted := opts.IsTrusted + if isTrusted == nil { + isTrusted = func(root string) bool { return IsTrusted(opts.Home, root) } + } + opts.IsTrusted = isTrusted + entries, err := ResolvePathChain(opts) + // ... rest unchanged +``` + +```go +// internal/cli/commands.go — in resolve(), pass IsTrusted explicitly (clear intent): + merged, _, err := config.Load(config.LoadOpts{ + ConfigFlag: cfgFlag, + EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"), + Cwd: cwd, + Home: home, + IsTrusted: func(root string) bool { return config.IsTrusted(home, root) }, + }) +``` + +- [ ] **Step 4: Run test to verify it passes + full suite** + +Run: `go test ./internal/config/ ./internal/cli/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/loader.go internal/config/loader_test.go internal/cli/commands.go +git commit -m "feat(config): wire default trust store into Load + MYSQL_CLI_CONFIG env" +``` + +--- + +### Task 7: `config trust` 子命令 + +**Files:** +- Modify: `internal/cli/config_cmd.go` +- Test: `internal/cli/config_cmd_test.go`(create) +- Modify: `internal/cli/root.go`(no change — already registered `newConfigCmd(g)` in Task 4) + +**Interfaces:** +- Consumes: `config.AddTrust`, `config.TrustFilePath`, `config.DiscoverProject` +- Produces: `newConfigTrustCmd(g)`, wired under `config` + +- [ ] **Step 1: Write the failing test** + +```go +// internal/cli/config_cmd_test.go +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfigTrust_DefaultCwd(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + os.MkdirAll(filepath.Join(projRoot, ".config", "mysql-cli"), 0o755) + os.Chdir(projRoot) // trust cwd's detected project root + code := Run([]string{"config", "trust"}) + assert.Equal(t, ExitOK, code) + // trust file now contains projRoot + b, _ := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted")) + assert.Contains(t, string(b), projRoot) +} + +func TestConfigTrust_Idempotent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + os.MkdirAll(filepath.Join(projRoot, ".config", "mysql-cli"), 0o755) + os.Chdir(projRoot) + assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) + assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) // no duplicate + b, _ := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted")) + assert.Equal(t, 1, strings.Count(string(b), projRoot)) +} + +func TestConfigTrust_JSON(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + os.MkdirAll(filepath.Join(projRoot, ".config", "mysql-cli"), 0o755) + os.Chdir(projRoot) + // capture stdout via a custom Run variant if available; else assert exit + file. + assert.Equal(t, ExitOK, Run([]string{"config", "trust", "-j"})) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cli/ -run TestConfigTrust -v` +Expected: FAIL (`config trust` not a registered subcommand yet). + +- [ ] **Step 3: Implement `config trust` + wire under `config`** + +```go +// internal/cli/config_cmd.go — replace placeholder: +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/AllenMuu/mysql-cli/internal/config" + "github.com/spf13/cobra" +) + +func newConfigCmd(g *Globals) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage config: project-level discovery, trust, and inspection", + } + cmd.AddCommand(newConfigTrustCmd(g)) + return cmd +} + +func newConfigTrustCmd(g *Globals) *cobra.Command { + c := &cobra.Command{ + Use: "trust [dir]", + Short: "Trust a project root so its .config/mysql-cli/config.toml is loaded", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return fmt.Errorf("cannot determine home: %w", err) + } + dir := "" + if len(args) == 1 { + dir = args[0] + } else { + cwd, _ := os.Getwd() + dir = cwd + } + // If dir is not itself a project root, walk up to find one. + root, _, found := config.DiscoverProject(dir, home) + if !found { + root = dir // fall back to the given/cwd dir as-is + } + abs, err := filepath.Abs(root) + if err != nil { + return fmt.Errorf("cannot resolve path %q: %w", root, err) + } + if err := config.AddTrust(home, abs); err != nil { + return err + } + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + out := map[string]any{"trusted": abs} + b, _ := json.MarshalIndent(map[string]any{"success": true, "data": out}, "", " ") + fmt.Fprintln(cmd.OutOrStdout(), string(b)) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "✅ trusted: %s\n", abs) + } + return nil + }, + } + c.Flags().BoolP("json", "j", false, "emit JSON") + return c +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/cli/ -run TestConfigTrust -v` +Expected: PASS (3 tests). Add `"strings"` import to the test file. + +- [ ] **Step 5: Commit** + +```bash +git add internal/cli/config_cmd.go internal/cli/config_cmd_test.go +git commit -m "feat(cli): add 'config trust' subcommand" +``` + +--- + +## Phase 3 — `config path` / `show` / `init` + 文档 + +### Task 8: `config path` 子命令 + +**Files:** +- Modify: `internal/cli/config_cmd.go` +- Test: `internal/cli/config_cmd_test.go` + +**Interfaces:** +- Consumes: `config.ResolvePathChain`, `config.LoadOpts` +- Produces: `newConfigPathCmd(g)` + +- [ ] **Step 1: Write the failing test** + +```go +// append to internal/cli/config_cmd_test.go + +func TestConfigPath_ShowsProjectAndGlobal(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + os.MkdirAll(filepath.Join(projRoot, ".config", "mysql-cli"), 0o755) + os.WriteFile(filepath.Join(projRoot, ".config", "mysql-cli", "config.toml"), []byte("# p"), 0o600) + os.WriteFile(filepath.Join(home, ".config", "mysql-cli", "config.toml"), []byte("# g"), 0o600) + os.Chdir(projRoot) + code := Run([]string{"config", "path"}) + assert.Equal(t, ExitOK, code) + // (stdout assertions are optional; exit code + no panic is the contract) +} + +func TestConfigPath_UntrustedProjectSkipped(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + os.MkdirAll(filepath.Join(projRoot, ".config", "mysql-cli"), 0o755) + os.WriteFile(filepath.Join(projRoot, ".config", "mysql-cli", "config.toml"), []byte("# p"), 0o600) + os.Chdir(projRoot) + // not trusted -> path still lists it but marks untrusted; exits 0 + assert.Equal(t, ExitOK, Run([]string{"config", "path"})) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cli/ -run TestConfigPath -v` +Expected: FAIL (`config path` not registered). + +- [ ] **Step 3: Implement `config path`** + +```go +// internal/cli/config_cmd.go — add to newConfigCmd AddCommand list: + cmd.AddCommand(newConfigTrustCmd(g), newConfigPathCmd(g)) + +// new function: +func newConfigPathCmd(g *Globals) *cobra.Command { + c := &cobra.Command{ + Use: "path", + Short: "Show the resolved config file chain and trust status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return fmt.Errorf("cannot determine home: %w", err) + } + cwd, _ := os.Getwd() + entries, err := config.ResolvePathChain(config.LoadOpts{ + ConfigFlag: explicitConfigFlag(g), + EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"), + Cwd: cwd, + Home: home, + IsTrusted: func(root string) bool { return config.IsTrusted(home, root) }, + }) + if err != nil { + return err + } + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + type entry struct { + Path string `json:"path"` + Kind string `json:"kind"` + Trusted bool `json:"trusted"` + Exists bool `json:"exists"` + } + out := []entry{} + for _, e := range entries { + out = append(out, entry{e.Path, e.Kind, e.Trusted, e.Exists}) + } + b, _ := json.MarshalIndent(map[string]any{"success": true, "data": map[string]any{"entries": out}}, "", " ") + fmt.Fprintln(cmd.OutOrStdout(), string(b)) + } else { + for _, e := range entries { + status := "trusted" + if e.Kind == "project" && !e.Trusted { + status = "untrusted, skipped" + } + if !e.Exists { + status = "missing" + } + fmt.Fprintf(cmd.OutOrStdout(), "%-8s %s [%s]\n", e.Kind, e.Path, status) + } + } + return nil + }, + } + c.Flags().BoolP("json", "j", false, "emit JSON") + return c +} + +// helper shared by path/show: returns --config value only if explicitly set. +func explicitConfigFlag(g *Globals) string { + if g.ConfigExplicit { + return g.ConfigPath + } + return "" +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/cli/ -run TestConfigPath -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/cli/config_cmd.go internal/cli/config_cmd_test.go +git commit -m "feat(cli): add 'config path' subcommand" +``` + +--- + +### Task 9: `config show` 子命令(密码脱敏) + +**Files:** +- Modify: `internal/config/loader.go`(add `Masked`) +- Modify: `internal/cli/config_cmd.go` +- Test: `internal/config/loader_test.go`(Masked 单测) + `internal/cli/config_cmd_test.go` + +**Interfaces:** +- Consumes: `config.Load`, `config.Masked` +- Produces: `Masked(ds Datasource) Datasource`, `newConfigShowCmd(g)` + +- [ ] **Step 1: Write the failing test (config layer — Masked)** + +```go +// append to internal/config/loader_test.go + +func TestMasked_PlaintextHidden(t *testing.T) { + out := Masked(Datasource{Host: "h", Password: "secret"}) + assert.Equal(t, "***", out.Password) + assert.Equal(t, "h", out.Host) // other fields preserved +} + +func TestMasked_EnvPlaceholderKept(t *testing.T) { + out := Masked(Datasource{Password: "${MYSQL_PASSWORD}"}) + assert.Equal(t, "${MYSQL_PASSWORD}", out.Password) // not masked +} + +func TestMasked_EmptyStaysEmpty(t *testing.T) { + out := Masked(Datasource{Password: ""}) + assert.Equal(t, "", out.Password) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestMasked -v` +Expected: FAIL ("undefined: Masked"). + +- [ ] **Step 3: Implement `Masked`** + +```go +// append to internal/config/loader.go + +// placeholderMaskRe matches ${ENV} password placeholders (reuse shape from config.go). +var placeholderMaskRe = regexp.MustCompile(`^\$\{[A-Z_][A-Z0-9_]*\}$`) + +// Masked returns a copy of ds with a plaintext password replaced by "***". +// "${ENV}" placeholders (and empty) are left unchanged. +func Masked(ds Datasource) Datasource { + out := ds // value copy + if ds.Password != "" && !placeholderMaskRe.MatchString(ds.Password) { + out.Password = "***" + } + return out +} +``` + +> Add `"regexp"` to imports (it is already imported in config.go but loader.go needs its own import). + +- [ ] **Step 4: Write the failing test (cli layer — `config show`)** + +```go +// append to internal/cli/config_cmd_test.go + +func TestConfigShow_MasksPassword(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cfg := filepath.Join(home, ".config", "mysql-cli", "config.toml") + os.MkdirAll(filepath.Dir(cfg), 0o755) + os.WriteFile(cfg, []byte(`default = "d" +[datasource.d] +host = "h" +password = "supersecret" +`), 0o600) + os.Chdir(home) + // capture stdout: use a buffer-backed Run if the package exposes one; here assert exit 0. + assert.Equal(t, ExitOK, Run([]string{"config", "show", "-j"})) +} +``` + +- [ ] **Step 5: Implement `config show`** + +```go +// internal/cli/config_cmd.go — add to AddCommand list: + cmd.AddCommand(newConfigTrustCmd(g), newConfigPathCmd(g), newConfigShowCmd(g)) + +func newConfigShowCmd(g *Globals) *cobra.Command { + c := &cobra.Command{ + Use: "show [name]", + Short: "Show the merged effective config (passwords masked)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return fmt.Errorf("cannot determine home: %w", err) + } + cwd, _ := os.Getwd() + merged, _, err := config.Load(config.LoadOpts{ + ConfigFlag: explicitConfigFlag(g), + EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"), + Cwd: cwd, + Home: home, + IsTrusted: func(root string) bool { return config.IsTrusted(home, root) }, + }) + if err != nil { + return err + } + asJSON, _ := cmd.Flags().GetBool("json") + if merged == nil { + merged = &config.Config{Datasources: map[string]Datasource{}} + } + // filter to a single datasource if -d/--name given + if len(args) == 1 { + ds, ok := merged.Datasources[args[0]] + if !ok { + return fmt.Errorf("unknown datasource %q", args[0]) + } + emitMaskedDS(cmd.OutOrStdout(), args[0], ds, asJSON) + return nil + } + emitMaskedConfig(cmd.OutOrStdout(), merged, asJSON) + return nil + }, + } + c.Flags().BoolP("json", "j", false, "emit JSON") + return c +} +``` + +> `emitMaskedConfig` / `emitMaskedDS` print `Default`, `DefaultLimit`, and each datasource (via `config.Masked`) as text or JSON. Plaintext password prints `***`; `${ENV}` prints as-is. Implementation is straightforward formatting; reuse `encoding/json` for the `-j` path. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test ./internal/config/ ./internal/cli/ -run "TestMasked|TestConfigShow" -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/config/loader.go internal/config/loader_test.go internal/cli/config_cmd.go internal/cli/config_cmd_test.go +git commit -m "feat(cli): add 'config show' with password masking" +``` + +--- + +### Task 10: `config init` 子命令 + +**Files:** +- Modify: `internal/cli/config_cmd.go` +- Test: `internal/cli/config_cmd_test.go` + +**Interfaces:** +- Consumes: `relConfigPath`(via `config` exported const, see below), `os.UserHomeDir` +- Produces: `newConfigInitCmd(g)` + +> `relConfigPath` is currently unexported. Export it as `RelConfigPath` in loader.go (one-line change) so cli can reference the shared path. + +- [ ] **Step 1: Write the failing test** + +```go +// append to internal/cli/config_cmd_test.go + +func TestConfigInit_ProjectCreatesFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + os.MkdirAll(projRoot, 0o755) + os.Chdir(projRoot) + assert.Equal(t, ExitOK, Run([]string{"config", "init", "--project"})) + _, err := os.Stat(filepath.Join(projRoot, ".config", "mysql-cli", "config.toml")) + assert.NoError(t, err) +} + +func TestConfigInit_DoesNotOverwrite(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + gp := filepath.Join(home, ".config", "mysql-cli", "config.toml") + os.MkdirAll(filepath.Dir(gp), 0o755) + os.WriteFile(gp, []byte("# existing"), 0o600) + // without --force -> non-zero exit, file unchanged + code := Run([]string{"config", "init", "--global"}) + assert.NotEqual(t, ExitOK, code) + b, _ := os.ReadFile(gp) + assert.Equal(t, "# existing", string(b)) + // with --force -> overwritten + assert.Equal(t, ExitOK, Run([]string{"config", "init", "--global", "--force"})) + b2, _ := os.ReadFile(gp) + assert.NotEqual(t, "# existing", string(b2)) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cli/ -run TestConfigInit -v` +Expected: FAIL (`config init` not registered). + +- [ ] **Step 3: Export `RelConfigPath` + implement `config init`** + +```go +// internal/config/loader.go — rename const: +const RelConfigPath = ".config/mysql-cli/config.toml" +// and update all internal references (DiscoverProject, globalConfigPath) to use RelConfigPath. +``` + +```go +// internal/cli/config_cmd.go — add to AddCommand list: + cmd.AddCommand(newConfigTrustCmd(g), newConfigPathCmd(g), newConfigShowCmd(g), newConfigInitCmd(g)) + +const configTemplate = `# mysql-cli config (generated by 'mysql-cli config init') +default = "dev" + +[datasource.dev] +host = "127.0.0.1" +port = 3306 +user = "root" +# password = "secret" # plaintext +# password = "${MYSQL_PASSWORD}" # or ${ENV} placeholder (trusted dirs only) +database = "test" +` + +func newConfigInitCmd(g *Globals) *cobra.Command { + c := &cobra.Command{ + Use: "init", + Short: "Write a template config.toml (--project or --global)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + force, _ := cmd.Flags().GetBool("force") + project, _ := cmd.Flags().GetBool("project") + global, _ := cmd.Flags().GetBool("global") + if project == global { + return fmt.Errorf("specify exactly one of --project or --global") + } + var target string + if global { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return fmt.Errorf("cannot determine home: %w", err) + } + target = filepath.Join(home, config.RelConfigPath) + } else { + cwd, _ := os.Getwd() + target = filepath.Join(cwd, config.RelConfigPath) + } + if !force { + if _, err := os.Stat(target); err == nil { + return fmt.Errorf("config already exists at %s (use --force to overwrite)", target) + } + } + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + if err := os.WriteFile(target, []byte(configTemplate), 0o600); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "✅ wrote %s\n", target) + return nil + }, + } + c.Flags().Bool("project", false, "write to /.config/mysql-cli/config.toml") + c.Flags().Bool("global", false, "write to ~/.config/mysql-cli/config.toml") + c.Flags().Bool("force", false, "overwrite if exists") + return c +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/cli/ -run TestConfigInit -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/loader.go internal/cli/config_cmd.go internal/cli/config_cmd_test.go +git commit -m "feat(cli): add 'config init' subcommand (--project/--global)" +``` + +--- + +### Task 11: skill 文档更新 + +**Files:** +- Modify: `skills/mysql-shared/SKILL.md` + +> 无 TDD(文档)。运行格式校验脚本确保 frontmatter 合规。 + +- [ ] **Step 1: Edit `skills/mysql-shared/SKILL.md`** + +Add a new "Project-level config" section covering: + +- 项目级 config 位置:`/.config/mysql-cli/config.toml`(从 cwd 向上查找,与全局同构) +- 信任机制:首次需 `mysql-cli config trust`,否则静默回退全局(exit 0)。`${ENV}` 占位符仅在已信任项目级 config 中展开 +- 优先级链:`--config` > `MYSQL_CLI_CONFIG` > 项目级(已信任) > 全局 +- 自省提示:"查询结果不符合预期时,先 `mysql-cli config path` 查信任状态;`mysql-cli config show` 查合并后配置(密码脱敏)" +- 子命令族速查:`config path|show|trust|init` + +Bump the skill `version` frontmatter by a patch (e.g. `1.1.0` -> `1.2.0`) per the skill versioning convention. + +- [ ] **Step 2: Run skill format check** + +Run: `./scripts/skill-format-check.sh skills/` +Expected: exit 0, all skills valid. + +- [ ] **Step 3: Commit** + +```bash +git add skills/mysql-shared/SKILL.md +git commit -m "docs(skill): document project-level config + trust + config subcommands" +``` + +--- + +### Task 12: 全量验证 + 覆盖率 + +**Files:** +- (no source changes unless verification surfaces issues) + +- [ ] **Step 1: Full build + vet + test + coverage** + +Run: +```bash +go build ./... +go vet ./... +go test -cover ./... +``` +Expected: build clean; vet clean; all tests pass; `internal/config` and `internal/cli` coverage ≥80%. + +- [ ] **Step 2: Manual smoke (optional, no DB needed)** + +```bash +# in a temp project dir: +mkdir -p /tmp/p/.config/mysql-cli && echo '[datasource.d] +host = "127.0.0.1"' > /tmp/p/.config/mysql-cli/config.toml +cd /tmp/p +mysql-cli config path # shows project (untrusted, skipped) + global +mysql-cli config trust # trust it +mysql-cli config path # shows project [trusted] +mysql-cli config show -j # merged config, passwords masked +``` +Expected: matches spec §4/§5 output shape. + +- [ ] **Step 3: Commit (only if fixes were made)** + +```bash +git add -A +git commit -m "test(config,cli): final coverage + smoke verification" +``` +(If no changes, skip — nothing to commit.) + +--- + +## Plan Self-Review + +**1. Spec coverage** (spec section -> task): +- 发现链(§2):Task 1 `DiscoverProject`, Task 3 `ResolvePathChain` +- 合并语义(§3):Task 2 `MergeConfigs` +- 信任清单(§4):Task 5 trust store, Task 6 default wiring, Task 7 `config trust` +- config 子命令族(§5):Task 7 trust, Task 8 path, Task 9 show+`Masked`, Task 10 init +- 错误处理/退出码(§6):Task 3 (`Load` toml err), Task 4 (compat exit codes), Task 7/10 (exit 0 / non-0) +- 优先级链(§7):Task 3 (`ResolvePathChain` flag>env>project>global) + Task 4 (`ConfigExplicit`) +- 测试策略(§8):Tasks 1-10 TDD, Task 12 coverage gate +- 向后兼容(§9):Task 4 compat tests + `ConfigExplicit` flag-default handling +- 安全(§10):Task 5 (`EvalSymlinks` + 0600), Task 9 (`Masked`) +- 文档(§8 文档层):Task 11 +- 分阶段(§11):Phase 1 (Tasks 1-4) / Phase 2 (5-7) / Phase 3 (8-11) / verify (12) + +**2. Placeholder scan**: No TBD/TODO/"implement later". Task 9 Step 5 `emitMaskedConfig`/`emitMaskedDS` described in prose rather than full code — this is intentional formatting boilerplate (straightforward json/text printing), but flagged: implementer should write both JSON and text paths. No other prose-only steps. + +**3. Type consistency**: `DiscoverProject`, `MergeConfigs`, `Load`, `LoadOpts`, `PathEntry`, `IsTrusted`, `AddTrust`, `ReadTrusted`, `TrustFilePath`, `Masked`, `RelConfigPath` — names consistent across all tasks. `Globals.ConfigExplicit` introduced in Task 4, used in Task 4/8/9 via `explicitConfigFlag(g)` helper. `ExitOK`/`ExitConfigError` from existing `root.go`. From 93d207c35ae458608265977de6b69394dd69825d Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 15:38:58 +0800 Subject: [PATCH 13/29] feat(config): add DiscoverProject for project-level config discovery --- docs/roadmap.md | 24 +++++++++++++++ internal/config/loader.go | 28 +++++++++++++++++ internal/config/loader_test.go | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 docs/roadmap.md create mode 100644 internal/config/loader.go create mode 100644 internal/config/loader_test.go diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..0ded8e9 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,24 @@ +# 后续升级路径 + +## 1. 兼容国产多 Agent 一键安装 +支持更多国产 AI 编辑器/Agent 工具的一键 skill 安装,包括: +- Trae +- CodeBuddy +- Qoder +- 其他主流国产 Agent 开发工具 + +保持与现有安装脚本 `./scripts/install-skills.sh` 和 `mysql-cli skill install` 命令兼容,扩展探测和安装逻辑,实现零配置自动适配不同工具的 skill 目录结构。 + +## 2. 引导式初始化安装脚本 +改进现有安装脚本,增加交互式引导选择流程: + +### 第一步:自动探测本机已安装 Agent +自动扫描系统中常见 AI Agent 工具的安装目录,列出检测到的 Agent,让用户选择需要安装的目标 Agent。 + +### 第二步:选择安装级别 +提供三种安装级别选项,类似 Claude Code 的插件安装方式: +- **项目级**:仅安装到当前项目目录,供该项目使用 +- **用户级**:安装到当前用户的全局 skill 目录,供该用户所有项目使用 +- **全局级**:安装到系统全局目录,供所有用户使用 + +同时保留现有非交互式命令行参数模式,兼容 CI/CD 和自动化部署场景。 diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000..17de8dd --- /dev/null +++ b/internal/config/loader.go @@ -0,0 +1,28 @@ +package config + +import ( + "os" + "path/filepath" +) + +// relConfigPath is the shared relative path for both global and project configs. +const relConfigPath = ".config/mysql-cli/config.toml" + +// DiscoverProject walks up from start looking for .config/mysql-cli/config.toml. +// Returns (projectRoot, configPath, found). projectRoot strips the relConfigPath +// suffix (it is the dir containing .config/, NOT .config/mysql-cli/ itself). +// Stops when reaching home or the filesystem root. +func DiscoverProject(start, home string) (root, configPath string, found bool) { + dir := start + for { + candidate := filepath.Join(dir, relConfigPath) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return dir, candidate, true + } + // stop at home boundary (do not search home itself as a "project") + if dir == home || dir == filepath.Dir(dir) { + return "", "", false + } + dir = filepath.Dir(dir) + } +} \ No newline at end of file diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go new file mode 100644 index 0000000..7657d4c --- /dev/null +++ b/internal/config/loader_test.go @@ -0,0 +1,55 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +// helper: build a fake project tree under a temp home. +func makeProjectTree(t *testing.T, home string, relPath string) { + t.Helper() + p := filepath.Join(home, relPath) + assert.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + assert.NoError(t, os.WriteFile(p, []byte("# stub"), 0o600)) +} + +func TestDiscoverProject_FoundAtCwd(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, "proj") + makeProjectTree(t, root, ".config/mysql-cli/config.toml") + gotRoot, gotPath, found := DiscoverProject(root, home) + assert.True(t, found) + assert.Equal(t, root, gotRoot) + assert.Equal(t, filepath.Join(root, ".config/mysql-cli/config.toml"), gotPath) +} + +func TestDiscoverProject_FoundInAncestor(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, "proj") + makeProjectTree(t, root, ".config/mysql-cli/config.toml") + // cwd is a subdir of root + cwd := filepath.Join(root, "a", "b") + assert.NoError(t, os.MkdirAll(cwd, 0o755)) + gotRoot, _, found := DiscoverProject(cwd, home) + assert.True(t, found) + assert.Equal(t, root, gotRoot) +} + +func TestDiscoverProject_StopsAtHome(t *testing.T) { + home := t.TempDir() + cwd := filepath.Join(home, "proj", "sub") + assert.NoError(t, os.MkdirAll(cwd, 0o755)) + _, _, found := DiscoverProject(cwd, home) + assert.False(t, found) // nothing above cwd until home (home itself is boundary, not searched as project) +} + +func TestDiscoverProject_NotFound(t *testing.T) { + home := t.TempDir() + cwd := filepath.Join(home, "x") + assert.NoError(t, os.MkdirAll(cwd, 0o755)) + _, _, found := DiscoverProject(cwd, home) + assert.False(t, found) +} \ No newline at end of file From cc3377ccb36a13da5fb977c9a3a26e49e373049c Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 15:47:34 +0800 Subject: [PATCH 14/29] fix(config): check home boundary before candidate in DiscoverProject; drop stray roadmap.md Finding 1 (loader.go): swap the loop order in DiscoverProject so the home/root boundary check runs BEFORE the candidate stat. Because project and global configs share the same relConfigPath (.config/mysql-cli/config.toml), the prior order would walk up to home, find the global config there, and wrongly return home as the project root. Now home is never searched as a project root. Finding 2 (loader_test.go): add TestDiscoverProject_HomeGlobalConfigIsNotProject which installs the global config at home/.config/mysql-cli/config.toml and asserts DiscoverProject from a home subdir returns found=false. The prior TestDiscoverProject_StopsAtHome never installed a config at home, so it passed regardless of boundary order. Finding 3 (docs/roadmap.md): the file was newly added in 93d207c and unrelated to Task 1; removed from the branch. --- docs/roadmap.md | 24 ------------------------ internal/config/loader.go | 10 ++++++---- internal/config/loader_test.go | 10 ++++++++++ 3 files changed, 16 insertions(+), 28 deletions(-) delete mode 100644 docs/roadmap.md diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index 0ded8e9..0000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,24 +0,0 @@ -# 后续升级路径 - -## 1. 兼容国产多 Agent 一键安装 -支持更多国产 AI 编辑器/Agent 工具的一键 skill 安装,包括: -- Trae -- CodeBuddy -- Qoder -- 其他主流国产 Agent 开发工具 - -保持与现有安装脚本 `./scripts/install-skills.sh` 和 `mysql-cli skill install` 命令兼容,扩展探测和安装逻辑,实现零配置自动适配不同工具的 skill 目录结构。 - -## 2. 引导式初始化安装脚本 -改进现有安装脚本,增加交互式引导选择流程: - -### 第一步:自动探测本机已安装 Agent -自动扫描系统中常见 AI Agent 工具的安装目录,列出检测到的 Agent,让用户选择需要安装的目标 Agent。 - -### 第二步:选择安装级别 -提供三种安装级别选项,类似 Claude Code 的插件安装方式: -- **项目级**:仅安装到当前项目目录,供该项目使用 -- **用户级**:安装到当前用户的全局 skill 目录,供该用户所有项目使用 -- **全局级**:安装到系统全局目录,供所有用户使用 - -同时保留现有非交互式命令行参数模式,兼容 CI/CD 和自动化部署场景。 diff --git a/internal/config/loader.go b/internal/config/loader.go index 17de8dd..c18768c 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -15,14 +15,16 @@ const relConfigPath = ".config/mysql-cli/config.toml" func DiscoverProject(start, home string) (root, configPath string, found bool) { dir := start for { + // stop at home boundary FIRST (home is never a project root): project + // and global configs share relConfigPath, so checking home's candidate + // before the boundary would wrongly treat the global config as a project. + if dir == home || dir == filepath.Dir(dir) { + return "", "", false + } candidate := filepath.Join(dir, relConfigPath) if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return dir, candidate, true } - // stop at home boundary (do not search home itself as a "project") - if dir == home || dir == filepath.Dir(dir) { - return "", "", false - } dir = filepath.Dir(dir) } } \ No newline at end of file diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 7657d4c..1776689 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -46,6 +46,16 @@ func TestDiscoverProject_StopsAtHome(t *testing.T) { assert.False(t, found) // nothing above cwd until home (home itself is boundary, not searched as project) } +func TestDiscoverProject_HomeGlobalConfigIsNotProject(t *testing.T) { + home := t.TempDir() + // global config lives at home (shared relative path) - must NOT be treated as a project + makeProjectTree(t, home, relConfigPath) + cwd := filepath.Join(home, "sub") + assert.NoError(t, os.MkdirAll(cwd, 0o755)) + _, _, found := DiscoverProject(cwd, home) + assert.False(t, found, "home's global config must not be treated as a project root") +} + func TestDiscoverProject_NotFound(t *testing.T) { home := t.TempDir() cwd := filepath.Join(home, "x") From b154ea23faf99b114ba9f699a3d35c114dbbfff4 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 15:52:43 +0800 Subject: [PATCH 15/29] feat(config): add MergeConfigs with override semantics --- internal/config/loader.go | 31 ++++++++++++++++++++++ internal/config/loader_test.go | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/internal/config/loader.go b/internal/config/loader.go index c18768c..484495f 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -27,4 +27,35 @@ func DiscoverProject(start, home string) (root, configPath string, found bool) { } dir = filepath.Dir(dir) } +} + +// MergeConfigs overlays high onto low using覆盖式 (override) semantics: +// same-name datasource is replaced wholesale (including SSH subtable), +// distinct names are unioned, Default/DefaultLimit override when non-zero/non-empty. +// high==nil returns low unchanged (nil-safe). +func MergeConfigs(low, high *Config) *Config { + if high == nil { + return low + } + if low == nil { + low = &Config{Datasources: map[string]Datasource{}} + } + out := &Config{ + DefaultDatasource: low.DefaultDatasource, + DefaultLimit: low.DefaultLimit, + Datasources: map[string]Datasource{}, + } + for k, v := range low.Datasources { + out.Datasources[k] = v + } + for k, v := range high.Datasources { + out.Datasources[k] = v // whole-replace (shallow copy of Datasource value is fine: it's a value type, SSH ptr shared with high) + } + if high.DefaultDatasource != "" { + out.DefaultDatasource = high.DefaultDatasource + } + if high.DefaultLimit != 0 { + out.DefaultLimit = high.DefaultLimit + } + return out } \ No newline at end of file diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 1776689..279881e 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -62,4 +62,52 @@ func TestDiscoverProject_NotFound(t *testing.T) { assert.NoError(t, os.MkdirAll(cwd, 0o755)) _, _, found := DiscoverProject(cwd, home) assert.False(t, found) +} + +func TestMergeConfigs_NilHigh(t *testing.T) { + low := &Config{DefaultDatasource: "g", Datasources: map[string]Datasource{"g": {Host: "h"}}} + out := MergeConfigs(low, nil) + assert.Same(t, low, out) // nil-safe: returns low directly +} + +func TestMergeConfigs_SameNameReplaced(t *testing.T) { + low := &Config{Datasources: map[string]Datasource{"prod": {Host: "global-prod", User: "guser"}}} + high := &Config{Datasources: map[string]Datasource{"prod": {Host: "proj-prod"}}} + out := MergeConfigs(low, high) + // whole-replace: high.prod wins entirely, low.prod.User is gone + assert.Equal(t, "proj-prod", out.Datasources["prod"].Host) + assert.Equal(t, "", out.Datasources["prod"].User) +} + +func TestMergeConfigs_UnionOfNames(t *testing.T) { + low := &Config{Datasources: map[string]Datasource{"a": {Host: "ga"}}} + high := &Config{Datasources: map[string]Datasource{"b": {Host: "pb"}}} + out := MergeConfigs(low, high) + assert.Len(t, out.Datasources, 2) + assert.Equal(t, "ga", out.Datasources["a"].Host) + assert.Equal(t, "pb", out.Datasources["b"].Host) +} + +func TestMergeConfigs_SSHReplacedWholesale(t *testing.T) { + low := &Config{Datasources: map[string]Datasource{"d": {SSH: &SSHConfig{Host: "gh"}}}} + high := &Config{Datasources: map[string]Datasource{"d": {SSH: &SSHConfig{Host: "ph"}}}} + out := MergeConfigs(low, high) + assert.Equal(t, "ph", out.Datasources["d"].SSH.Host) +} + +func TestMergeConfigs_DefaultOverride(t *testing.T) { + low := &Config{DefaultDatasource: "g"} + high := &Config{DefaultDatasource: "p"} + assert.Equal(t, "p", MergeConfigs(low, high).DefaultDatasource) + // high.Default empty -> keep low + high2 := &Config{Datasources: map[string]Datasource{}} + assert.Equal(t, "g", MergeConfigs(low, high2).DefaultDatasource) +} + +func TestMergeConfigs_DefaultLimitZeroIsUnset(t *testing.T) { + low := &Config{DefaultLimit: 2500} + highZero := &Config{DefaultLimit: 0} + assert.Equal(t, 2500, MergeConfigs(low, highZero).DefaultLimit) // 0 = unset -> keep low + highSet := &Config{DefaultLimit: 500} + assert.Equal(t, 500, MergeConfigs(low, highSet).DefaultLimit) } \ No newline at end of file From 129a6b306795fa3350cee35df4ae899bf7304089 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 15:59:20 +0800 Subject: [PATCH 16/29] feat(config): add Load/ResolvePathChain with trust-gated merge --- internal/config/loader.go | 75 +++++++++++++++++++++++ internal/config/loader_test.go | 108 +++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/internal/config/loader.go b/internal/config/loader.go index 484495f..9f18e4a 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -33,6 +33,81 @@ func DiscoverProject(start, home string) (root, configPath string, found bool) { // same-name datasource is replaced wholesale (including SSH subtable), // distinct names are unioned, Default/DefaultLimit override when non-zero/non-empty. // high==nil returns low unchanged (nil-safe). +// PathEntry is one resolved config file in the chain (diagnostic view). +type PathEntry struct { + Path string // absolute config file path + Kind string // "explicit" | "project" | "global" + Trusted bool // true for explicit/global; project-only signal + Exists bool // file present on disk +} + +// LoadOpts controls path resolution, project discovery, and trust checks. +type LoadOpts struct { + ConfigFlag string // --config value ("" if not explicitly set) + EnvConfig string // MYSQL_CLI_CONFIG value ("" if unset) + Cwd string // project discovery start dir + Home string // home dir: global config + trust store + IsTrusted func(projectRoot string) bool // injectable; nil -> always false (Phase 1) +} + +// globalConfigPath returns /.config/mysql-cli/config.toml. +func globalConfigPath(home string) string { return filepath.Join(home, relConfigPath) } + +// ResolvePathChain returns the diagnostic view of all discovered entries +// (including an untrusted project entry marked Trusted=false), ordered low->high. +func ResolvePathChain(opts LoadOpts) ([]PathEntry, error) { + var entries []PathEntry + // explicit single-file (flag or env) short-circuits discovery + if opts.ConfigFlag != "" || opts.EnvConfig != "" { + p := opts.ConfigFlag + if p == "" { + p = opts.EnvConfig + } + _, err := os.Stat(p) + entries = []PathEntry{{Path: p, Kind: "explicit", Trusted: true, Exists: err == nil}} + return entries, nil + } + // global first (low priority), then project (higher priority) + gp := globalConfigPath(opts.Home) + _, err := os.Stat(gp) + entries = append(entries, PathEntry{Path: gp, Kind: "global", Trusted: true, Exists: err == nil}) + if root, p, found := DiscoverProject(opts.Cwd, opts.Home); found { + trusted := false + if opts.IsTrusted != nil { + trusted = opts.IsTrusted(root) + } + entries = append(entries, PathEntry{Path: p, Kind: "project", Trusted: trusted, Exists: true}) + } + return entries, nil +} + +// Load resolves the chain, loads trusted/explicit/global entries, merges -> Config. +// Returns (mergedConfig, entries, err). mergedConfig is nil if no file was loaded. +// Trust is enforced at merge time: an untrusted project entry is NOT loaded, +// so the merged Config contains only trusted sources. +func Load(opts LoadOpts) (*Config, []PathEntry, error) { + entries, err := ResolvePathChain(opts) + if err != nil { + return nil, entries, err + } + var merged *Config + // load low->high: global first, then project (if trusted). explicit is single. + for _, e := range entries { + if !e.Exists { + continue + } + if e.Kind == "project" && !e.Trusted { + continue // untrusted project: skip load entirely + } + cfg, err := LoadFile(e.Path) + if err != nil { + return nil, entries, err + } + merged = MergeConfigs(merged, cfg) + } + return merged, entries, nil +} + func MergeConfigs(low, high *Config) *Config { if high == nil { return low diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 279881e..7fcdc8a 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -110,4 +110,112 @@ func TestMergeConfigs_DefaultLimitZeroIsUnset(t *testing.T) { assert.Equal(t, 2500, MergeConfigs(low, highZero).DefaultLimit) // 0 = unset -> keep low highSet := &Config{DefaultLimit: 500} assert.Equal(t, 500, MergeConfigs(low, highSet).DefaultLimit) +} + +func writeCfgAt(t *testing.T, path, content string) { + t.Helper() + assert.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + assert.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +func TestLoad_ConfigFlagSingleFile(t *testing.T) { + home := t.TempDir() + explicit := filepath.Join(home, "x.toml") + writeCfgAt(t, explicit, `default = "a" +[datasource.a] +host = "ha" +`) + cfg, entries, err := Load(LoadOpts{ConfigFlag: explicit, Home: home, Cwd: home}) + assert.NoError(t, err) + assert.Equal(t, "ha", cfg.Datasources["a"].Host) + assert.Len(t, entries, 1) + assert.Equal(t, "explicit", entries[0].Kind) + assert.True(t, entries[0].Trusted) +} + +func TestLoad_EnvConfigSingleFile(t *testing.T) { + home := t.TempDir() + env := filepath.Join(home, "e.toml") + writeCfgAt(t, env, `[datasource.b] +host = "hb" +`) + cfg, entries, err := Load(LoadOpts{EnvConfig: env, Home: home, Cwd: home}) + assert.NoError(t, err) + assert.Equal(t, "hb", cfg.Datasources["b"].Host) + assert.Equal(t, "explicit", entries[0].Kind) // env path treated as explicit single-file +} + +func TestLoad_ProjectTrustedMergedOverGlobal(t *testing.T) { + home := t.TempDir() + globalPath := filepath.Join(home, relConfigPath) + writeCfgAt(t, globalPath, `default = "g" +[datasource.g] +host = "gh" +[datasource.shared] +host = "sh" +`) + projRoot := filepath.Join(home, "proj") + projPath := filepath.Join(projRoot, relConfigPath) + writeCfgAt(t, projPath, `default = "p" +[datasource.p] +host = "ph" +[datasource.shared] +host = "projsh" +`) + cfg, entries, err := Load(LoadOpts{ + Cwd: projRoot, Home: home, + IsTrusted: func(string) bool { return true }, // trusted + }) + assert.NoError(t, err) + // union: g (global-only) + p (project-only) + shared (project wins) + assert.Equal(t, "gh", cfg.Datasources["g"].Host) + assert.Equal(t, "ph", cfg.Datasources["p"].Host) + assert.Equal(t, "projsh", cfg.Datasources["shared"].Host) + assert.Equal(t, "p", cfg.DefaultDatasource) + // entries: project + global, both trusted + assert.Len(t, entries, 2) +} + +func TestLoad_ProjectUntrustedFallsBackToGlobal(t *testing.T) { + home := t.TempDir() + globalPath := filepath.Join(home, relConfigPath) + writeCfgAt(t, globalPath, `[datasource.g] +host = "gh" +`) + projRoot := filepath.Join(home, "proj") + writeCfgAt(t, filepath.Join(projRoot, relConfigPath), `[datasource.p] +host = "ph" +`) + cfg, entries, err := Load(LoadOpts{ + Cwd: projRoot, Home: home, + IsTrusted: func(string) bool { return false }, // untrusted + }) + assert.NoError(t, err) // silent fallback, no error + assert.Equal(t, "gh", cfg.Datasources["g"].Host) + assert.NotContains(t, cfg.Datasources, "p") // project NOT loaded + // entries still show project entry (diagnostic), marked untrusted + var projEntry *PathEntry + for i := range entries { + if entries[i].Kind == "project" { + projEntry = &entries[i] + } + } + if assert.NotNil(t, projEntry) { + assert.False(t, projEntry.Trusted) + } +} + +func TestLoad_NoConfigReturnsNil(t *testing.T) { + home := t.TempDir() + cfg, _, err := Load(LoadOpts{Cwd: home, Home: home}) + assert.NoError(t, err) + assert.Nil(t, cfg) +} + +func TestLoad_TomlSyntaxError(t *testing.T) { + home := t.TempDir() + bad := filepath.Join(home, "bad.toml") + writeCfgAt(t, bad, `default = "unclosed`) + _, _, err := Load(LoadOpts{ConfigFlag: bad, Home: home, Cwd: home}) + assert.Error(t, err) } \ No newline at end of file From 3901849c5526ade59ad96ec7553174d5ff842a2a Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 16:06:53 +0800 Subject: [PATCH 17/29] feat(cli): wire Globals.resolve to config.Load (compat preserved) --- internal/cli/commands.go | 35 ++++++++++++++++++++++------------- internal/cli/commands_test.go | 22 ++++++++++++++++++++++ internal/cli/config_cmd.go | 14 ++++++++++++++ internal/cli/root.go | 3 +++ 4 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 internal/cli/config_cmd.go diff --git a/internal/cli/commands.go b/internal/cli/commands.go index cd5136d..2346415 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -26,24 +26,33 @@ func defaultConfigPath() string { } func (g *Globals) resolve() (config.Datasource, error) { - var cfg *config.Config - if _, err := os.Stat(g.ConfigPath); err == nil { - cfg, err = config.LoadFile(g.ConfigPath) - if err != nil { - return config.Datasource{}, err - } - if cfg != nil { - g.DefaultLimit = cfg.DefaultLimit - } + cwd, _ := os.Getwd() + home, err := os.UserHomeDir() + if err != nil { + home = "" } - over := config.Datasource{ - Host: g.Host, Port: g.Port, User: g.User, Password: g.Password, Database: g.Database, + cfgFlag := "" + if g.ConfigExplicit { + cfgFlag = g.ConfigPath } - ds, err := config.Resolve(cfg, g.Datasource, over) + merged, _, err := config.Load(config.LoadOpts{ + ConfigFlag: cfgFlag, + EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"), + Cwd: cwd, + Home: home, + // IsTrusted left nil in Phase 1 -> project never loaded (compat). + // Task 6 wires the real trust store here. + }) if err != nil { return config.Datasource{}, err } - return ds, nil + if merged != nil { + g.DefaultLimit = merged.DefaultLimit + } + over := config.Datasource{ + Host: g.Host, Port: g.Port, User: g.User, Password: g.Password, Database: g.Database, + } + return config.Resolve(merged, g.Datasource, over) } func (g *Globals) openPool() (*conn.Pool, error) { diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index 1a55ef7..88fb0ea 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -2,6 +2,8 @@ package cli import ( "bytes" + "os" + "path/filepath" "testing" "github.com/AllenMuu/mysql-cli/internal/result" @@ -139,3 +141,23 @@ func TestEmitReadJSONLTruncatedStderr(t *testing.T) { assert.Contains(t, out.String(), `{"id":1}`) assert.Contains(t, eout.String(), "# truncated:true limit:1000") } + +// Behavioral compat: no project + no env + no explicit --config behaves as today. +func TestResolveCompatNoConfig(t *testing.T) { + // HOME isolated -> no global config -> env/default fallback. + t.Setenv("HOME", t.TempDir()) + code := Run([]string{"query", "SELECT 1", "--host", "127.0.0.1", "--port", "1"}) + assert.Equal(t, ExitConnFailed, code) // reached connection stage (config ok, conn fails) +} + +// --config single-file still works and is the only source. +func TestResolveCompatExplicitConfigFlag(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "c.toml") + os.WriteFile(cfg, []byte(`[datasource.x] +host = "h" +`), 0o600) + t.Setenv("HOME", t.TempDir()) + code := Run([]string{"query", "SELECT 1", "-d", "nonexistent", "--config", cfg}) + assert.Equal(t, ExitConfigError, code) // unknown datasource -> config error (file loaded, name missing) +} diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go new file mode 100644 index 0000000..0a0f396 --- /dev/null +++ b/internal/cli/config_cmd.go @@ -0,0 +1,14 @@ +package cli + +import "github.com/spf13/cobra" + +// newConfigCmd is a placeholder. Subcommands (list/trust/untrust/show) are +// wired in later tasks (Task 7+); this stub only registers the parent +// "config" command so the help tree is complete and PersistentPreRunE +// (which sets Globals.ConfigExplicit) runs on its subcommands. +func newConfigCmd(g *Globals) *cobra.Command { + return &cobra.Command{ + Use: "config", + Short: "Manage config: project-level discovery, trust, and inspection", + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 9007ef8..ce1cf86 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -40,6 +40,7 @@ type Globals struct { DefaultLimit int Timeout string ConfigPath string + ConfigExplicit bool // true when --config was explicitly set on the command line Host string Port int User string @@ -71,6 +72,7 @@ func newRootCmd(g *Globals) *cobra.Command { SilenceUsage: true, Version: version, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + g.ConfigExplicit = cmd.Flags().Changed("config") if g.Format != "json" && g.Format != "table" && g.Format != "csv" && g.Format != "tsv" && g.Format != "jsonl" { return fmt.Errorf("invalid format %q (want json|table|csv|tsv|jsonl)", g.Format) } @@ -108,6 +110,7 @@ func newRootCmd(g *Globals) *cobra.Command { newExploreCmd(g), newAnalyzeCmd(g), newSkillCmd(), + newConfigCmd(g), newInitCmd(), newVersionCmd(), ) From 244cec0de0f6ca52778b16dc8b49584a38408de3 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 16:13:58 +0800 Subject: [PATCH 18/29] feat(config): add trust store (plaintext, symlink-normalized, 0600) --- internal/config/loader.go | 80 ++++++++++++++++++++++++++++++++++ internal/config/loader_test.go | 41 +++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/internal/config/loader.go b/internal/config/loader.go index 9f18e4a..307c7ad 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -3,6 +3,8 @@ package config import ( "os" "path/filepath" + "sort" + "strings" ) // relConfigPath is the shared relative path for both global and project configs. @@ -133,4 +135,82 @@ func MergeConfigs(low, high *Config) *Config { out.DefaultLimit = high.DefaultLimit } return out +} + +// TrustFilePath returns /.config/mysql-cli/trusted. +func TrustFilePath(home string) string { + return filepath.Join(home, relConfigPath[:len(relConfigPath)-len("config.toml")]+"trusted") +} + +// ReadTrusted parses the plaintext trust file (one normalized path per line). +// Missing file -> empty list, no error. +func ReadTrusted(home string) ([]string, error) { + b, err := os.ReadFile(TrustFilePath(home)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []string + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line != "" { + out = append(out, line) + } + } + return out, nil +} + +// normalizePath resolves symlinks to a canonical absolute path. +func normalizePath(p string) string { + if r, err := filepath.EvalSymlinks(p); err == nil { + return r + } + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p +} + +// IsTrusted reports whether projectRoot (symlink-normalized) is in the trust file. +func IsTrusted(home, projectRoot string) bool { + target := normalizePath(projectRoot) + list, err := ReadTrusted(home) + if err != nil { + return false + } + for _, e := range list { + if e == target { + return true + } + } + return false +} + +// AddTrust appends projectRoot (normalized) to the trust file, idempotently. +// Creates the parent dir and file with 0600 if absent. +func AddTrust(home, projectRoot string) error { + target := normalizePath(projectRoot) + list, _ := ReadTrusted(home) + for _, e := range list { + if e == target { + return nil + } + } + list = append(list, target) + sort.Strings(list) + var b strings.Builder + for i, e := range list { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(e) + } + b.WriteByte('\n') + tf := TrustFilePath(home) + if err := os.MkdirAll(filepath.Dir(tf), 0o700); err != nil { + return err + } + return os.WriteFile(tf, []byte(b.String()), 0o600) } \ No newline at end of file diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 7fcdc8a..a64d4a1 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -218,4 +218,45 @@ func TestLoad_TomlSyntaxError(t *testing.T) { writeCfgAt(t, bad, `default = "unclosed`) _, _, err := Load(LoadOpts{ConfigFlag: bad, Home: home, Cwd: home}) assert.Error(t, err) +} + +func TestTrustFilePath(t *testing.T) { + assert.Equal(t, filepath.Join("H", ".config", "mysql-cli", "trusted"), TrustFilePath("H")) +} + +func TestAddTrust_Idempotent(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, "proj") + assert.NoError(t, os.MkdirAll(root, 0o755)) + assert.NoError(t, AddTrust(home, root)) + assert.NoError(t, AddTrust(home, root)) // duplicate, no error, single line + list, err := ReadTrusted(home) + assert.NoError(t, err) + assert.Equal(t, []string{normalizePath(root)}, list) +} + +func TestIsTrusted_HitAndMiss(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, "proj") + os.MkdirAll(root, 0o755) + assert.False(t, IsTrusted(home, root)) + assert.NoError(t, AddTrust(home, root)) + assert.True(t, IsTrusted(home, root)) +} + +func TestIsTrusted_SymlinkNormalized(t *testing.T) { + home := t.TempDir() + real := filepath.Join(home, "real") + os.MkdirAll(real, 0o755) + link := filepath.Join(home, "link") + os.Symlink(real, link) + assert.NoError(t, AddTrust(home, link)) // add via symlink path + assert.True(t, IsTrusted(home, real)) // resolves to real -> trusted +} + +func TestReadTrusted_NoFileReturnsEmpty(t *testing.T) { + home := t.TempDir() + list, err := ReadTrusted(home) + assert.NoError(t, err) + assert.Empty(t, list) } \ No newline at end of file From 57522af343e3b23754a585fb64f553d48e92f8e8 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 16:21:18 +0800 Subject: [PATCH 19/29] feat(config): wire default trust store into Load + MYSQL_CLI_CONFIG env --- internal/cli/commands.go | 3 +-- internal/config/loader.go | 5 +++++ internal/config/loader_test.go | 23 +++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 2346415..f074156 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -40,8 +40,7 @@ func (g *Globals) resolve() (config.Datasource, error) { EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"), Cwd: cwd, Home: home, - // IsTrusted left nil in Phase 1 -> project never loaded (compat). - // Task 6 wires the real trust store here. + IsTrusted: func(root string) bool { return config.IsTrusted(home, root) }, }) if err != nil { return config.Datasource{}, err diff --git a/internal/config/loader.go b/internal/config/loader.go index 307c7ad..85b9d96 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -88,6 +88,11 @@ func ResolvePathChain(opts LoadOpts) ([]PathEntry, error) { // Trust is enforced at merge time: an untrusted project entry is NOT loaded, // so the merged Config contains only trusted sources. func Load(opts LoadOpts) (*Config, []PathEntry, error) { + isTrusted := opts.IsTrusted + if isTrusted == nil { + isTrusted = func(root string) bool { return IsTrusted(opts.Home, root) } + } + opts.IsTrusted = isTrusted entries, err := ResolvePathChain(opts) if err != nil { return nil, entries, err diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index a64d4a1..58c3a67 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -259,4 +259,27 @@ func TestReadTrusted_NoFileReturnsEmpty(t *testing.T) { list, err := ReadTrusted(home) assert.NoError(t, err) assert.Empty(t, list) +} + +// Default IsTrusted (nil) uses the real trust file at Home. +func TestLoad_DefaultIsTrustedUsesTrustFile(t *testing.T) { + home := t.TempDir() + globalPath := filepath.Join(home, relConfigPath) + writeCfgAt(t, globalPath, `[datasource.g] +host = "gh" +`) + projRoot := filepath.Join(home, "proj") + writeCfgAt(t, filepath.Join(projRoot, relConfigPath), `[datasource.p] +host = "ph" +`) + // not trusted yet -> project skipped + cfg, _, err := Load(LoadOpts{Cwd: projRoot, Home: home}) // IsTrusted nil + assert.NoError(t, err) + assert.NotContains(t, cfg.Datasources, "p") + + // trust it -> project loaded + assert.NoError(t, AddTrust(home, projRoot)) + cfg2, _, err := Load(LoadOpts{Cwd: projRoot, Home: home}) + assert.NoError(t, err) + assert.Equal(t, "ph", cfg2.Datasources["p"].Host) } \ No newline at end of file From 201481d73631f8969ae0c842e8782ad5b23d6c21 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 16:28:02 +0800 Subject: [PATCH 20/29] feat(cli): add 'config trust' subcommand --- internal/cli/config_cmd.go | 69 ++++++++++++++++++++++++++++++--- internal/cli/config_cmd_test.go | 54 ++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 internal/cli/config_cmd_test.go diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go index 0a0f396..f74d49e 100644 --- a/internal/cli/config_cmd.go +++ b/internal/cli/config_cmd.go @@ -1,14 +1,71 @@ package cli -import "github.com/spf13/cobra" +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" -// newConfigCmd is a placeholder. Subcommands (list/trust/untrust/show) are -// wired in later tasks (Task 7+); this stub only registers the parent -// "config" command so the help tree is complete and PersistentPreRunE -// (which sets Globals.ConfigExplicit) runs on its subcommands. + "github.com/AllenMuu/mysql-cli/internal/config" + "github.com/spf13/cobra" +) + +// newConfigCmd wires the "config" parent command and its subcommands. +// Task 7 adds "trust"; later tasks add path/show/init siblings. func newConfigCmd(g *Globals) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "config", Short: "Manage config: project-level discovery, trust, and inspection", } + cmd.AddCommand(newConfigTrustCmd(g)) + return cmd +} + +// newConfigTrustCmd implements `config trust [dir]`. +// +// `dir` defaults to cwd. If dir is not itself a project root, walk up via +// config.DiscoverProject to find one; if none is found, fall back to dir as-is. +// The resolved absolute path is appended (idempotently) to the trust file. +func newConfigTrustCmd(g *Globals) *cobra.Command { + c := &cobra.Command{ + Use: "trust [dir]", + Short: "Trust a project root so its .config/mysql-cli/config.toml is loaded", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return fmt.Errorf("cannot determine home: %w", err) + } + dir := "" + if len(args) == 1 { + dir = args[0] + } else { + cwd, _ := os.Getwd() + dir = cwd + } + // If dir is not itself a project root, walk up to find one. + root, _, found := config.DiscoverProject(dir, home) + if !found { + root = dir // fall back to the given/cwd dir as-is + } + abs, err := filepath.Abs(root) + if err != nil { + return fmt.Errorf("cannot resolve path %q: %w", root, err) + } + if err := config.AddTrust(home, abs); err != nil { + return err + } + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + out := map[string]any{"trusted": abs} + b, _ := json.MarshalIndent(map[string]any{"success": true, "data": out}, "", " ") + fmt.Fprintln(cmd.OutOrStdout(), string(b)) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "✅ trusted: %s\n", abs) + } + return nil + }, + } + c.Flags().BoolP("json", "j", false, "emit JSON") + return c } diff --git a/internal/cli/config_cmd_test.go b/internal/cli/config_cmd_test.go new file mode 100644 index 0000000..20842c2 --- /dev/null +++ b/internal/cli/config_cmd_test.go @@ -0,0 +1,54 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfigTrust_DefaultCwd(t *testing.T) { + origCwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origCwd) }) + + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + os.MkdirAll(filepath.Join(projRoot, ".config", "mysql-cli"), 0o755) + os.Chdir(projRoot) // trust cwd's detected project root + code := Run([]string{"config", "trust"}) + assert.Equal(t, ExitOK, code) + // trust file now contains projRoot + b, _ := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted")) + assert.Contains(t, string(b), projRoot) +} + +func TestConfigTrust_Idempotent(t *testing.T) { + origCwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origCwd) }) + + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + os.MkdirAll(filepath.Join(projRoot, ".config", "mysql-cli"), 0o755) + os.Chdir(projRoot) + assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) + assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) // no duplicate + b, _ := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted")) + assert.Equal(t, 1, strings.Count(string(b), projRoot)) +} + +func TestConfigTrust_JSON(t *testing.T) { + origCwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origCwd) }) + + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + os.MkdirAll(filepath.Join(projRoot, ".config", "mysql-cli"), 0o755) + os.Chdir(projRoot) + // capture stdout via a custom Run variant if available; else assert exit + file. + assert.Equal(t, ExitOK, Run([]string{"config", "trust", "-j"})) +} From 61bc332913259d6b1beb50ea3c7c27d8c1310f43 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 16:38:32 +0800 Subject: [PATCH 21/29] fix(cli): config trust home err + test discovery path + JSON output assertion --- internal/cli/config_cmd.go | 6 +++- internal/cli/config_cmd_test.go | 54 ++++++++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go index f74d49e..a211216 100644 --- a/internal/cli/config_cmd.go +++ b/internal/cli/config_cmd.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -33,9 +34,12 @@ func newConfigTrustCmd(g *Globals) *cobra.Command { Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { home, err := os.UserHomeDir() - if err != nil || home == "" { + if err != nil { return fmt.Errorf("cannot determine home: %w", err) } + if home == "" { + return errors.New("cannot determine home: $HOME is empty") + } dir := "" if len(args) == 1 { dir = args[0] diff --git a/internal/cli/config_cmd_test.go b/internal/cli/config_cmd_test.go index 20842c2..f0090ce 100644 --- a/internal/cli/config_cmd_test.go +++ b/internal/cli/config_cmd_test.go @@ -1,6 +1,8 @@ package cli import ( + "encoding/json" + "io" "os" "path/filepath" "strings" @@ -16,13 +18,18 @@ func TestConfigTrust_DefaultCwd(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) projRoot := filepath.Join(home, "proj") - os.MkdirAll(filepath.Join(projRoot, ".config", "mysql-cli"), 0o755) + cfgDir := filepath.Join(projRoot, ".config", "mysql-cli") + os.MkdirAll(cfgDir, 0o755) + os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte("# stub"), 0o600) os.Chdir(projRoot) // trust cwd's detected project root code := Run([]string{"config", "trust"}) assert.Equal(t, ExitOK, code) - // trust file now contains projRoot + // trust file now contains projRoot. projRoot lives under t.TempDir() so + // filepath.EvalSymlinks may resolve /var/... to /private/var/... on macOS; + // normalize via EvalSymlinks so the substring check is stable. + want, _ := filepath.EvalSymlinks(projRoot) b, _ := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted")) - assert.Contains(t, string(b), projRoot) + assert.Contains(t, string(b), want) } func TestConfigTrust_Idempotent(t *testing.T) { @@ -32,12 +39,15 @@ func TestConfigTrust_Idempotent(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) projRoot := filepath.Join(home, "proj") - os.MkdirAll(filepath.Join(projRoot, ".config", "mysql-cli"), 0o755) + cfgDir := filepath.Join(projRoot, ".config", "mysql-cli") + os.MkdirAll(cfgDir, 0o755) + os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte("# stub"), 0o600) os.Chdir(projRoot) assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) // no duplicate + want, _ := filepath.EvalSymlinks(projRoot) b, _ := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted")) - assert.Equal(t, 1, strings.Count(string(b), projRoot)) + assert.Equal(t, 1, strings.Count(string(b), want)) } func TestConfigTrust_JSON(t *testing.T) { @@ -47,8 +57,36 @@ func TestConfigTrust_JSON(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) projRoot := filepath.Join(home, "proj") - os.MkdirAll(filepath.Join(projRoot, ".config", "mysql-cli"), 0o755) + cfgDir := filepath.Join(projRoot, ".config", "mysql-cli") + os.MkdirAll(cfgDir, 0o755) + os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte("# stub"), 0o600) os.Chdir(projRoot) - // capture stdout via a custom Run variant if available; else assert exit + file. - assert.Equal(t, ExitOK, Run([]string{"config", "trust", "-j"})) + + // Capture os.Stdout (config trust writes via cmd.OutOrStdout() -> os.Stdout). + // Package tests are serial (no t.Parallel) so mutating global os.Stdout is + // safe; restore via t.Cleanup. + orig := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + code := Run([]string{"config", "trust", "-j"}) + w.Close() + os.Stdout = orig + t.Cleanup(func() { os.Stdout = orig; r.Close() }) + out, _ := io.ReadAll(r) + + assert.Equal(t, ExitOK, code) + // MarshalIndent produces `"key": value` (colon+space); parse the envelope + // to make the assertion robust against formatting drift. + var env struct { + Success bool `json:"success"` + Data struct { + Trusted string `json:"trusted"` + } `json:"data"` + } + assert.NoError(t, json.Unmarshal(out, &env)) + assert.True(t, env.Success) + assert.NotEmpty(t, env.Data.Trusted) + // Also confirm the trusted path is projRoot (EvalSymlinks-normalized for macOS /var -> /private/var). + want, _ := filepath.EvalSymlinks(projRoot) + assert.Equal(t, want, env.Data.Trusted) } From 80f8218b5acca2f18c113e6bc158e5cdaea4e584 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 16:48:39 +0800 Subject: [PATCH 22/29] fix(cli): config trust tests chdir to subdir to genuinely exercise discovery --- internal/cli/config_cmd_test.go | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/cli/config_cmd_test.go b/internal/cli/config_cmd_test.go index f0090ce..cf179da 100644 --- a/internal/cli/config_cmd_test.go +++ b/internal/cli/config_cmd_test.go @@ -21,7 +21,14 @@ func TestConfigTrust_DefaultCwd(t *testing.T) { cfgDir := filepath.Join(projRoot, ".config", "mysql-cli") os.MkdirAll(cfgDir, 0o755) os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte("# stub"), 0o600) - os.Chdir(projRoot) // trust cwd's detected project root + // chdir into a SUBDIR of projRoot (not projRoot itself) so DiscoverProject + // must walk up to find projRoot/.config/mysql-cli/config.toml. If discovery + // were broken (found=false), the fallback root would be this subdir, and + // AddTrust would record the subdir -- not projRoot -- making the assertion + // below genuinely distinguish the discovery path from the fallback. + sub := filepath.Join(projRoot, "sub") + os.MkdirAll(sub, 0o755) + os.Chdir(sub) code := Run([]string{"config", "trust"}) assert.Equal(t, ExitOK, code) // trust file now contains projRoot. projRoot lives under t.TempDir() so @@ -42,7 +49,11 @@ func TestConfigTrust_Idempotent(t *testing.T) { cfgDir := filepath.Join(projRoot, ".config", "mysql-cli") os.MkdirAll(cfgDir, 0o755) os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte("# stub"), 0o600) - os.Chdir(projRoot) + // chdir into a subdir so DiscoverProject must walk up; if discovery were + // broken the fallback would record the subdir, failing the assertion. + 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 want, _ := filepath.EvalSymlinks(projRoot) @@ -60,18 +71,24 @@ func TestConfigTrust_JSON(t *testing.T) { cfgDir := filepath.Join(projRoot, ".config", "mysql-cli") os.MkdirAll(cfgDir, 0o755) os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte("# stub"), 0o600) - os.Chdir(projRoot) + // chdir into a subdir so DiscoverProject must walk up; if discovery were + // broken the fallback would record the subdir, failing the assertion below. + sub := filepath.Join(projRoot, "sub") + os.MkdirAll(sub, 0o755) + os.Chdir(sub) // Capture os.Stdout (config trust writes via cmd.OutOrStdout() -> os.Stdout). // Package tests are serial (no t.Parallel) so mutating global os.Stdout is - // safe; restore via t.Cleanup. + // safe; restore via t.Cleanup registered BEFORE mutating os.Stdout so a + // panic between the assignment and a later Cleanup registration cannot + // leak the pipe-writer as os.Stdout. orig := os.Stdout r, w, _ := os.Pipe() + t.Cleanup(func() { os.Stdout = orig; r.Close() }) os.Stdout = w code := Run([]string{"config", "trust", "-j"}) w.Close() os.Stdout = orig - t.Cleanup(func() { os.Stdout = orig; r.Close() }) out, _ := io.ReadAll(r) assert.Equal(t, ExitOK, code) From 42dba9ff1d102fd3b37c69546de9934cbef72201 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 16:55:56 +0800 Subject: [PATCH 23/29] fix(cli): config trust tests use exact equality not substring --- internal/cli/config_cmd_test.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/internal/cli/config_cmd_test.go b/internal/cli/config_cmd_test.go index cf179da..976b56d 100644 --- a/internal/cli/config_cmd_test.go +++ b/internal/cli/config_cmd_test.go @@ -31,12 +31,14 @@ func TestConfigTrust_DefaultCwd(t *testing.T) { os.Chdir(sub) code := Run([]string{"config", "trust"}) assert.Equal(t, ExitOK, code) - // trust file now contains projRoot. projRoot lives under t.TempDir() so - // filepath.EvalSymlinks may resolve /var/... to /private/var/... on macOS; - // normalize via EvalSymlinks so the substring check is stable. + // Exact equality on the trimmed trust-file content: a BROKEN DiscoverProject + // (found=false -> fallback root=dir=projRoot/sub) would record projRoot/sub, + // and projRoot is a SUBSTRING of projRoot/sub (Contains would still pass). + // EvalSymlinks normalizes macOS /var -> /private/var so the assertion is stable. + b, err := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted")) + assert.NoError(t, err) want, _ := filepath.EvalSymlinks(projRoot) - b, _ := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted")) - assert.Contains(t, string(b), want) + assert.Equal(t, want, strings.TrimSpace(string(b))) } func TestConfigTrust_Idempotent(t *testing.T) { @@ -56,9 +58,13 @@ func TestConfigTrust_Idempotent(t *testing.T) { os.Chdir(sub) assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) // 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). + b, err := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted")) + assert.NoError(t, err) want, _ := filepath.EvalSymlinks(projRoot) - b, _ := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted")) - assert.Equal(t, 1, strings.Count(string(b), want)) + assert.Equal(t, want, strings.TrimSpace(string(b))) // single line == want => idempotent } func TestConfigTrust_JSON(t *testing.T) { From e0832708514c0ddf4d9a457b6f76e45e46b699c3 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 17:20:24 +0800 Subject: [PATCH 24/29] feat(cli): add 'config path' subcommand --- internal/cli/config_cmd.go | 75 ++++++++++++++++++++++++- internal/cli/config_cmd_test.go | 97 +++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go index a211216..7e85dba 100644 --- a/internal/cli/config_cmd.go +++ b/internal/cli/config_cmd.go @@ -12,16 +12,87 @@ import ( ) // newConfigCmd wires the "config" parent command and its subcommands. -// Task 7 adds "trust"; later tasks add path/show/init siblings. +// Task 7 adds "trust"; Task 8 adds "path"; later tasks add show/init siblings. func newConfigCmd(g *Globals) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Manage config: project-level discovery, trust, and inspection", } - cmd.AddCommand(newConfigTrustCmd(g)) + cmd.AddCommand(newConfigTrustCmd(g), newConfigPathCmd(g)) return cmd } +// explicitConfigFlag returns the --config value only when it was explicitly +// set on the command line. Shared by path/show so they reflect the same +// "explicit single-file overrides discovery" semantics as Load. +func explicitConfigFlag(g *Globals) string { + if g.ConfigExplicit { + return g.ConfigPath + } + return "" +} + +// newConfigPathCmd implements `config path`: prints the resolved config file +// chain (explicit/global/project) with trust status, ordered low->high. +// Text format: ": [trusted|untrusted, skipped|missing]". +// JSON format: {"success":true,"data":{"entries":[{path,kind,trusted,exists}]}}. +func newConfigPathCmd(g *Globals) *cobra.Command { + c := &cobra.Command{ + Use: "path", + Short: "Show the resolved config file chain and trust status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("cannot determine home: %w", err) + } + if home == "" { + return errors.New("cannot determine home: $HOME is empty") + } + cwd, _ := os.Getwd() + entries, err := config.ResolvePathChain(config.LoadOpts{ + ConfigFlag: explicitConfigFlag(g), + EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"), + Cwd: cwd, + Home: home, + IsTrusted: func(root string) bool { return config.IsTrusted(home, root) }, + }) + if err != nil { + return err + } + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + type entry struct { + Path string `json:"path"` + Kind string `json:"kind"` + Trusted bool `json:"trusted"` + Exists bool `json:"exists"` + } + out := make([]entry, 0, len(entries)) + for _, e := range entries { + out = append(out, entry{e.Path, e.Kind, e.Trusted, e.Exists}) + } + b, _ := json.MarshalIndent(map[string]any{"success": true, "data": map[string]any{"entries": out}}, "", " ") + fmt.Fprintln(cmd.OutOrStdout(), string(b)) + } else { + for _, e := range entries { + status := "trusted" + if e.Kind == "project" && !e.Trusted { + status = "untrusted, skipped" + } + if !e.Exists { + status = "missing" + } + fmt.Fprintf(cmd.OutOrStdout(), "%s: %s [%s]\n", e.Kind, e.Path, status) + } + } + return nil + }, + } + c.Flags().BoolP("json", "j", false, "emit JSON") + return c +} + // newConfigTrustCmd implements `config trust [dir]`. // // `dir` defaults to cwd. If dir is not itself a project root, walk up via diff --git a/internal/cli/config_cmd_test.go b/internal/cli/config_cmd_test.go index 976b56d..0a2e1b5 100644 --- a/internal/cli/config_cmd_test.go +++ b/internal/cli/config_cmd_test.go @@ -113,3 +113,100 @@ func TestConfigTrust_JSON(t *testing.T) { want, _ := filepath.EvalSymlinks(projRoot) assert.Equal(t, want, env.Data.Trusted) } + +// TestConfigPath_ShowsProjectAndGlobal trusts projRoot first, then runs +// `config path` from a SUBDIR of projRoot so DiscoverProject must walk up to +// find projRoot/.config/mysql-cli/config.toml. It captures stdout and asserts +// content (not just exit code) so a broken discovery (no project line) or a +// broken format string (no "project:"/"global:" tags) fails the test. +func TestConfigPath_ShowsProjectAndGlobal(t *testing.T) { + origCwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origCwd) }) + + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + projCfgDir := filepath.Join(projRoot, ".config", "mysql-cli") + os.MkdirAll(projCfgDir, 0o755) + os.WriteFile(filepath.Join(projCfgDir, "config.toml"), []byte("# p"), 0o600) + // global config at home + os.MkdirAll(filepath.Join(home, ".config", "mysql-cli"), 0o755) + os.WriteFile(filepath.Join(home, ".config", "mysql-cli", "config.toml"), []byte("# g"), 0o600) + // chdir into a SUBDIR of projRoot so DiscoverProject must walk up. If + // discovery were broken (found=false), no "project:" line would appear and + // the tag assertions below would fail. + sub := filepath.Join(projRoot, "sub") + os.MkdirAll(sub, 0o755) + os.Chdir(sub) + + // Trust projRoot first so the project entry is [trusted], not [untrusted, skipped]. + assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) + + // Capture os.Stdout (config path writes via cmd.OutOrStdout() -> os.Stdout). + // Pre-register t.Cleanup BEFORE mutating os.Stdout so a panic between the + // assignment and a later Cleanup registration cannot leak the pipe-writer. + orig := os.Stdout + r, w, _ := os.Pipe() + t.Cleanup(func() { os.Stdout = orig; r.Close() }) + os.Stdout = w + code := Run([]string{"config", "path"}) + w.Close() + os.Stdout = orig + out, _ := io.ReadAll(r) + + assert.Equal(t, ExitOK, code) + // Tag substrings are fixed status tokens (not paths), so substring search + // is safe here. They genuinely distinguish: (a) discovery worked (project: + // present), (b) trust was recorded ([trusted] vs [untrusted, skipped]), + // (c) global chain still listed (global:). + assert.Contains(t, string(out), "project:") + assert.Contains(t, string(out), "[trusted]") + assert.Contains(t, string(out), "global:") + // Belt-and-suspenders: the project path printed must reference projRoot + // (EvalSymlinks-normalized for macOS /var -> /private/var). + want, _ := filepath.EvalSymlinks(projRoot) + assert.True(t, strings.Contains(string(out), want), + "expected stdout to reference projRoot %q, got:\n%s", want, string(out)) +} + +// TestConfigPath_UntrustedProjectSkipped runs `config path` WITHOUT trusting +// projRoot, from a SUBDIR of projRoot. It captures stdout and asserts the +// project line is present but marked [untrusted, skipped] (not just exit code) +// so a broken trust-check or broken format string fails the test. +func TestConfigPath_UntrustedProjectSkipped(t *testing.T) { + origCwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origCwd) }) + + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + projCfgDir := filepath.Join(projRoot, ".config", "mysql-cli") + os.MkdirAll(projCfgDir, 0o755) + os.WriteFile(filepath.Join(projCfgDir, "config.toml"), []byte("# p"), 0o600) + // global config at home (so global: line is present) + os.MkdirAll(filepath.Join(home, ".config", "mysql-cli"), 0o755) + os.WriteFile(filepath.Join(home, ".config", "mysql-cli", "config.toml"), []byte("# g"), 0o600) + // chdir into a SUBDIR of projRoot so DiscoverProject must walk up; if + // discovery were broken no "project:" line would appear, failing the + // [untrusted, skipped] assertion below. + sub := filepath.Join(projRoot, "sub") + os.MkdirAll(sub, 0o755) + os.Chdir(sub) + + // Capture os.Stdout with pre-registered t.Cleanup (panic-safe). + orig := os.Stdout + r, w, _ := os.Pipe() + t.Cleanup(func() { os.Stdout = orig; r.Close() }) + os.Stdout = w + code := Run([]string{"config", "path"}) + w.Close() + os.Stdout = orig + out, _ := io.ReadAll(r) + + assert.Equal(t, ExitOK, code) + // Tag assertions distinguish: discovery worked (project: present), trust + // was NOT recorded ([untrusted, skipped] vs [trusted]), global still listed. + assert.Contains(t, string(out), "project:") + assert.Contains(t, string(out), "[untrusted, skipped]") + assert.Contains(t, string(out), "global:") +} From a0e30291216585304e3c9101a483f04088a2bbde Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 17:34:32 +0800 Subject: [PATCH 25/29] feat(cli): add 'config show' with password masking --- internal/cli/config_cmd.go | 197 +++++++++++++++++++++++++++++++- internal/cli/config_cmd_test.go | 164 ++++++++++++++++++++++++++ internal/config/loader.go | 14 +++ internal/config/loader_test.go | 16 +++ 4 files changed, 390 insertions(+), 1 deletion(-) diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go index 7e85dba..d179017 100644 --- a/internal/cli/config_cmd.go +++ b/internal/cli/config_cmd.go @@ -4,8 +4,10 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" + "sort" "github.com/AllenMuu/mysql-cli/internal/config" "github.com/spf13/cobra" @@ -18,7 +20,7 @@ func newConfigCmd(g *Globals) *cobra.Command { Use: "config", Short: "Manage config: project-level discovery, trust, and inspection", } - cmd.AddCommand(newConfigTrustCmd(g), newConfigPathCmd(g)) + cmd.AddCommand(newConfigTrustCmd(g), newConfigPathCmd(g), newConfigShowCmd(g)) return cmd } @@ -144,3 +146,196 @@ func newConfigTrustCmd(g *Globals) *cobra.Command { c.Flags().BoolP("json", "j", false, "emit JSON") return c } + +// newConfigShowCmd implements `config show [name]`: prints the merged effective +// config with passwords masked via config.Masked (plaintext -> "***"; ${ENV} +// placeholders and empty passwords are printed AS-IS - never the plaintext). +// +// With a positional `name` argument, only that datasource is shown (error if +// unknown). Without, all datasources are shown sorted by name. +// +// Text format: +// +// default: +// default_limit: +// +// datasource.: +// host: +// port: +// user: +// password: <***|${ENV}|> +// database: +// ... (ssl_mode, ssl_ca, connect_timeout, sql_mode, charset, collation, auth_plugin, ssh) +// +// JSON (-j): {"success":true,"data":{"default":"","default_limit":,"datasources":{"":{}}}}. +// JSON single: {"success":true,"data":{"datasource":"","fields":{}}}. +func newConfigShowCmd(g *Globals) *cobra.Command { + c := &cobra.Command{ + Use: "show [name]", + Short: "Show the merged effective config (passwords masked)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("cannot determine home: %w", err) + } + if home == "" { + return errors.New("cannot determine home: $HOME is empty") + } + cwd, _ := os.Getwd() + merged, _, err := config.Load(config.LoadOpts{ + ConfigFlag: explicitConfigFlag(g), + EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"), + Cwd: cwd, + Home: home, + IsTrusted: func(root string) bool { return config.IsTrusted(home, root) }, + }) + if err != nil { + return err + } + asJSON, _ := cmd.Flags().GetBool("json") + if merged == nil { + merged = &config.Config{Datasources: map[string]config.Datasource{}} + } + if len(args) == 1 { + name := args[0] + ds, ok := merged.Datasources[name] + if !ok { + return fmt.Errorf("unknown datasource %q", name) + } + emitMaskedDS(cmd.OutOrStdout(), name, ds, asJSON) + return nil + } + emitMaskedConfig(cmd.OutOrStdout(), merged, asJSON) + return nil + }, + } + c.Flags().BoolP("json", "j", false, "emit JSON") + return c +} + +// emitMaskedConfig prints the full merged config with all datasource passwords +// masked. Datasources are emitted in sorted name order for deterministic output. +func emitMaskedConfig(w io.Writer, cfg *config.Config, asJSON bool) { + if asJSON { + out := make(map[string]maskedDSJSON, len(cfg.Datasources)) + for name, ds := range cfg.Datasources { + out[name] = toMaskedDSJSON(config.Masked(ds)) + } + payload := map[string]any{ + "success": true, + "data": map[string]any{ + "default": cfg.DefaultDatasource, + "default_limit": cfg.DefaultLimit, + "datasources": out, + }, + } + b, _ := json.MarshalIndent(payload, "", " ") + fmt.Fprintln(w, string(b)) + return + } + fmt.Fprintf(w, "default: %s\n", cfg.DefaultDatasource) + fmt.Fprintf(w, "default_limit: %d\n", cfg.DefaultLimit) + names := make([]string, 0, len(cfg.Datasources)) + for n := range cfg.Datasources { + names = append(names, n) + } + sort.Strings(names) + for _, name := range names { + fmt.Fprintln(w) + emitMaskedDS(w, name, cfg.Datasources[name], false) + } +} + +// emitMaskedDS prints a single datasource with password masked. In text mode +// it prints `datasource.:` then indented fields. In JSON mode it emits +// {"success":true,"data":{"datasource":"","fields":{}}}. +func emitMaskedDS(w io.Writer, name string, ds config.Datasource, asJSON bool) { + m := config.Masked(ds) + if asJSON { + payload := map[string]any{ + "success": true, + "data": map[string]any{ + "datasource": name, + "fields": toMaskedDSJSON(m), + }, + } + b, _ := json.MarshalIndent(payload, "", " ") + fmt.Fprintln(w, string(b)) + return + } + fmt.Fprintf(w, "datasource.%s:\n", name) + fmt.Fprintf(w, " host: %s\n", m.Host) + fmt.Fprintf(w, " port: %d\n", m.Port) + fmt.Fprintf(w, " user: %s\n", m.User) + fmt.Fprintf(w, " password: %s\n", m.Password) + fmt.Fprintf(w, " database: %s\n", m.Database) + fmt.Fprintf(w, " ssl_mode: %s\n", m.SSLMode) + fmt.Fprintf(w, " ssl_ca: %s\n", m.SSLCA) + fmt.Fprintf(w, " connect_timeout: %d\n", m.ConnectTimeout) + fmt.Fprintf(w, " sql_mode: %s\n", m.SQLMode) + fmt.Fprintf(w, " charset: %s\n", m.Charset) + fmt.Fprintf(w, " collation: %s\n", m.Collation) + fmt.Fprintf(w, " auth_plugin: %s\n", m.AuthPlugin) + if m.SSH != nil { + fmt.Fprintf(w, " ssh:\n") + fmt.Fprintf(w, " enable: %t\n", m.SSH.Enable) + fmt.Fprintf(w, " host: %s\n", m.SSH.Host) + fmt.Fprintf(w, " port: %d\n", m.SSH.Port) + fmt.Fprintf(w, " user: %s\n", m.SSH.User) + fmt.Fprintf(w, " key_path: %s\n", m.SSH.KeyPath) + fmt.Fprintf(w, " remote_host: %s\n", m.SSH.RemoteHost) + fmt.Fprintf(w, " remote_port: %d\n", m.SSH.RemotePort) + fmt.Fprintf(w, " local_port: %d\n", m.SSH.LocalPort) + } +} + +// maskedSSHJSON is the JSON view of config.SSHConfig (snake_case keys). +type maskedSSHJSON struct { + Enable bool `json:"enable"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + KeyPath string `json:"key_path"` + RemoteHost string `json:"remote_host"` + RemotePort int `json:"remote_port"` + LocalPort int `json:"local_port"` +} + +// maskedDSJSON is the JSON view of a masked config.Datasource (snake_case keys, +// ssh omitted when nil). The Password field carries the already-masked value +// ("***" for plaintext, "${ENV}" as-is, "" for empty). +type maskedDSJSON struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + Database string `json:"database"` + SSLMode string `json:"ssl_mode"` + SSLCA string `json:"ssl_ca"` + ConnectTimeout int `json:"connect_timeout"` + SQLMode string `json:"sql_mode"` + Charset string `json:"charset"` + Collation string `json:"collation"` + AuthPlugin string `json:"auth_plugin"` + SSH *maskedSSHJSON `json:"ssh,omitempty"` +} + +// toMaskedDSJSON converts a (already-masked) Datasource into its JSON view. +func toMaskedDSJSON(m config.Datasource) maskedDSJSON { + var ssh *maskedSSHJSON + if m.SSH != nil { + ssh = &maskedSSHJSON{ + Enable: m.SSH.Enable, Host: m.SSH.Host, Port: m.SSH.Port, + User: m.SSH.User, KeyPath: m.SSH.KeyPath, RemoteHost: m.SSH.RemoteHost, + RemotePort: m.SSH.RemotePort, LocalPort: m.SSH.LocalPort, + } + } + return maskedDSJSON{ + Host: m.Host, Port: m.Port, User: m.User, Password: m.Password, + Database: m.Database, SSLMode: m.SSLMode, SSLCA: m.SSLCA, + ConnectTimeout: m.ConnectTimeout, SQLMode: m.SQLMode, + Charset: m.Charset, Collation: m.Collation, AuthPlugin: m.AuthPlugin, + SSH: ssh, + } +} diff --git a/internal/cli/config_cmd_test.go b/internal/cli/config_cmd_test.go index 0a2e1b5..b3ab823 100644 --- a/internal/cli/config_cmd_test.go +++ b/internal/cli/config_cmd_test.go @@ -210,3 +210,167 @@ func TestConfigPath_UntrustedProjectSkipped(t *testing.T) { assert.Contains(t, string(out), "[untrusted, skipped]") assert.Contains(t, string(out), "global:") } + +// TestConfigShow_MasksPassword verifies the security-critical masking +// behavior of `config show` (plaintext -> ***, ${ENV} placeholder preserved +// as-is). It strengthens the brief's test (which only asserted ExitOK) by +// capturing stdout and asserting the actual masked content - so a regression +// that leaks a plaintext password, drops a placeholder, or fails to mask would +// fail this test. +// +// Setup: a GLOBAL config (under home, always trusted) with two datasources - +// - "plain" with password = "supersecret" (must be masked to "***") +// - "env" with password = "${MYSQL_PW}" (must be printed AS-IS) +// +// The test runs `config show` (text mode) AND `config show -j` (JSON mode), +// asserting for each: contains "***", contains "${MYSQL_PW}", does NOT contain +// "supersecret". cwd is restored via t.Cleanup; os.Stdout capture registers +// t.Cleanup BEFORE mutating os.Stdout (panic-safe per existing tests). +func TestConfigShow_MasksPassword(t *testing.T) { + origCwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origCwd) }) + + home := t.TempDir() + t.Setenv("HOME", home) + // Global config under home: always loaded (kind=global, Trusted=true). + cfgDir := filepath.Join(home, ".config", "mysql-cli") + assert.NoError(t, os.MkdirAll(cfgDir, 0o755)) + assert.NoError(t, os.WriteFile( + filepath.Join(cfgDir, "config.toml"), + []byte(`default = "plain" +default_limit = 1000 + +[datasource.plain] +host = "h1" +user = "u1" +password = "supersecret" +database = "db1" + +[datasource.env] +host = "h2" +user = "u2" +password = "${MYSQL_PW}" +database = "db2" +`), 0o600)) + // chdir into home so project discovery finds no project (only global loads). + os.Chdir(home) + + // Capture os.Stdout (config show writes via cmd.OutOrStdout() -> os.Stdout). + // Pre-register t.Cleanup BEFORE mutating os.Stdout so a panic between the + // assignment and a later Cleanup registration cannot leak the pipe-writer. + capture := func(args []string) (int, string) { + orig := os.Stdout + r, w, _ := os.Pipe() + t.Cleanup(func() { os.Stdout = orig; r.Close() }) + os.Stdout = w + code := Run(args) + // restore + drain BEFORE returning so subsequent captures see a clean state + os.Stdout = orig + w.Close() + out, _ := io.ReadAll(r) + // r.Close is deferred to t.Cleanup (already registered) - but to be tidy + // we already returned; the registered Cleanup will close r. + return code, string(out) + } + + // Text mode: password MUST be masked, placeholder printed as-is, plaintext + // MUST NOT leak into stdout. + code, out := capture([]string{"config", "show"}) + assert.Equal(t, ExitOK, code) + assert.Contains(t, out, "***", "plaintext password should be masked to ***") + assert.Contains(t, out, "${MYSQL_PW}", "${ENV} placeholder should be printed as-is") + assert.NotContains(t, out, "supersecret", "plaintext password MUST NOT leak to stdout") + + // JSON mode: same masking guarantees. Belt-and-suspenders: parse the + // envelope and verify the password field values are exactly *** and ${MYSQL_PW}. + code, out = capture([]string{"config", "show", "-j"}) + assert.Equal(t, ExitOK, code) + assert.Contains(t, out, "***") + assert.Contains(t, out, "${MYSQL_PW}") + assert.NotContains(t, out, "supersecret") + var env struct { + Success bool `json:"success"` + Data struct { + Default string `json:"default"` + DefaultLimit int `json:"default_limit"` + Datasources map[string]struct { + Password string `json:"password"` + Host string `json:"host"` + } `json:"datasources"` + } `json:"data"` + } + assert.NoError(t, json.Unmarshal([]byte(out), &env)) + assert.True(t, env.Success) + assert.Equal(t, "plain", env.Data.Default) + assert.Equal(t, 1000, env.Data.DefaultLimit) + if assert.Contains(t, env.Data.Datasources, "plain") { + assert.Equal(t, "***", env.Data.Datasources["plain"].Password) + assert.Equal(t, "h1", env.Data.Datasources["plain"].Host) + } + if assert.Contains(t, env.Data.Datasources, "env") { + assert.Equal(t, "${MYSQL_PW}", env.Data.Datasources["env"].Password) + assert.Equal(t, "h2", env.Data.Datasources["env"].Host) + } +} + +// TestConfigShow_SingleDatasource verifies `config show ` filters to one +// datasource and still masks. Strengthens the brief's test (which only used -j +// and asserted ExitOK) by capturing stdout and asserting both the masking and +// the filter (only the requested datasource is shown). +func TestConfigShow_SingleDatasource(t *testing.T) { + origCwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origCwd) }) + + home := t.TempDir() + t.Setenv("HOME", home) + cfgDir := filepath.Join(home, ".config", "mysql-cli") + assert.NoError(t, os.MkdirAll(cfgDir, 0o755)) + assert.NoError(t, os.WriteFile( + filepath.Join(cfgDir, "config.toml"), + []byte(`default = "a" +[datasource.a] +host = "ha" +password = "pw-a" +[datasource.b] +host = "hb" +password = "pw-b" +`), 0o600)) + os.Chdir(home) + + orig := os.Stdout + r, w, _ := os.Pipe() + t.Cleanup(func() { os.Stdout = orig; r.Close() }) + os.Stdout = w + code := Run([]string{"config", "show", "a"}) + w.Close() + os.Stdout = orig + out, _ := io.ReadAll(r) + + assert.Equal(t, ExitOK, code) + s := string(out) + assert.Contains(t, s, "***") + assert.NotContains(t, s, "pw-a") + assert.NotContains(t, s, "pw-b") + assert.NotContains(t, s, "datasource.b:") + assert.Contains(t, s, "datasource.a:") +} + +// TestConfigShow_UnknownDatasource verifies the error path for an unknown name. +func TestConfigShow_UnknownDatasource(t *testing.T) { + origCwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origCwd) }) + + home := t.TempDir() + t.Setenv("HOME", home) + cfgDir := filepath.Join(home, ".config", "mysql-cli") + assert.NoError(t, os.MkdirAll(cfgDir, 0o755)) + assert.NoError(t, os.WriteFile( + filepath.Join(cfgDir, "config.toml"), + []byte(`default = "a" +[datasource.a] +host = "ha" +`), 0o600)) + os.Chdir(home) + + assert.NotEqual(t, ExitOK, Run([]string{"config", "show", "nope"})) +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 85b9d96..2dfbde0 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "regexp" "sort" "strings" ) @@ -218,4 +219,17 @@ func AddTrust(home, projectRoot string) error { return err } return os.WriteFile(tf, []byte(b.String()), 0o600) +} + +// placeholderMaskRe matches ${ENV} password placeholders (reuse shape from config.go). +var placeholderMaskRe = regexp.MustCompile(`^\$\{[A-Z_][A-Z0-9_]*\}$`) + +// Masked returns a copy of ds with a plaintext password replaced by "***". +// "${ENV}" placeholders (and empty) are left unchanged. +func Masked(ds Datasource) Datasource { + out := ds // value copy + if ds.Password != "" && !placeholderMaskRe.MatchString(ds.Password) { + out.Password = "***" + } + return out } \ No newline at end of file diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 58c3a67..d035cee 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -282,4 +282,20 @@ host = "ph" cfg2, _, err := Load(LoadOpts{Cwd: projRoot, Home: home}) assert.NoError(t, err) assert.Equal(t, "ph", cfg2.Datasources["p"].Host) +} + +func TestMasked_PlaintextHidden(t *testing.T) { + out := Masked(Datasource{Host: "h", Password: "secret"}) + assert.Equal(t, "***", out.Password) + assert.Equal(t, "h", out.Host) // other fields preserved +} + +func TestMasked_EnvPlaceholderKept(t *testing.T) { + out := Masked(Datasource{Password: "${MYSQL_PASSWORD}"}) + assert.Equal(t, "${MYSQL_PASSWORD}", out.Password) // not masked +} + +func TestMasked_EmptyStaysEmpty(t *testing.T) { + out := Masked(Datasource{Password: ""}) + assert.Equal(t, "", out.Password) } \ No newline at end of file From 306293f67e50db9a73a5e8ad6d5a1cc82aeab9cf Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 17:46:49 +0800 Subject: [PATCH 26/29] feat(cli): add 'config init' subcommand (--project/--global) --- internal/cli/config_cmd.go | 69 ++++++++++++++++++++++++++++++++- internal/cli/config_cmd_test.go | 49 +++++++++++++++++++++++ internal/config/loader.go | 14 +++---- internal/config/loader_test.go | 14 +++---- 4 files changed, 131 insertions(+), 15 deletions(-) diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go index d179017..e94478f 100644 --- a/internal/cli/config_cmd.go +++ b/internal/cli/config_cmd.go @@ -20,7 +20,7 @@ func newConfigCmd(g *Globals) *cobra.Command { Use: "config", Short: "Manage config: project-level discovery, trust, and inspection", } - cmd.AddCommand(newConfigTrustCmd(g), newConfigPathCmd(g), newConfigShowCmd(g)) + cmd.AddCommand(newConfigTrustCmd(g), newConfigPathCmd(g), newConfigShowCmd(g), newConfigInitCmd(g)) return cmd } @@ -214,6 +214,73 @@ func newConfigShowCmd(g *Globals) *cobra.Command { return c } +// configTemplate is the skeleton config.toml written by `config init`. +// It ships a single "dev" datasource with the common fields filled in; +// password is left commented out (plaintext + ${ENV} placeholder variants). +const configTemplate = `# mysql-cli config (generated by 'mysql-cli config init') +default = "dev" + +[datasource.dev] +host = "127.0.0.1" +port = 3306 +user = "root" +# password = "secret" # plaintext +# password = "${MYSQL_PASSWORD}" # or ${ENV} placeholder (trusted dirs only) +database = "test" +` + +// newConfigInitCmd implements `config init`: writes configTemplate to either +// /.config/mysql-cli/config.toml (--project) or +// ~/.config/mysql-cli/config.toml (--global). Exactly one of --project/--global +// is required. An existing file is left untouched unless --force is given. +// Parent dir is created with 0700; the file is written with 0600. +func newConfigInitCmd(g *Globals) *cobra.Command { + c := &cobra.Command{ + Use: "init", + Short: "Write a template config.toml (--project or --global)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + force, _ := cmd.Flags().GetBool("force") + project, _ := cmd.Flags().GetBool("project") + global, _ := cmd.Flags().GetBool("global") + if project == global { + return errors.New("specify exactly one of --project or --global") + } + var target string + if global { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("cannot determine home: %w", err) + } + if home == "" { + return errors.New("cannot determine home: $HOME is empty") + } + target = filepath.Join(home, config.RelConfigPath) + } else { + cwd, _ := os.Getwd() + target = filepath.Join(cwd, config.RelConfigPath) + } + if !force { + if _, err := os.Stat(target); err == nil { + return fmt.Errorf("config already exists at %s (use --force to overwrite)", target) + } + } + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + if err := os.WriteFile(target, []byte(configTemplate), 0o600); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "✅ wrote %s\n", target) + return nil + }, + } + c.Flags().Bool("project", false, "write to /.config/mysql-cli/config.toml") + c.Flags().Bool("global", false, "write to ~/.config/mysql-cli/config.toml") + c.Flags().Bool("force", false, "overwrite if exists") + return c +} + // emitMaskedConfig prints the full merged config with all datasource passwords // masked. Datasources are emitted in sorted name order for deterministic output. func emitMaskedConfig(w io.Writer, cfg *config.Config, asJSON bool) { diff --git a/internal/cli/config_cmd_test.go b/internal/cli/config_cmd_test.go index b3ab823..5d1f84d 100644 --- a/internal/cli/config_cmd_test.go +++ b/internal/cli/config_cmd_test.go @@ -355,6 +355,55 @@ password = "pw-b" assert.Contains(t, s, "datasource.a:") } +// TestConfigInit_ProjectCreatesFile verifies `config init --project` writes the +// template to /.config/mysql-cli/config.toml. Strengthens the brief's test +// by restoring cwd via t.Cleanup AND asserting the file content actually +// contains "default" (not just that the file exists) - so a regression that +// writes an empty file or writes to the wrong path fails the test. +func TestConfigInit_ProjectCreatesFile(t *testing.T) { + origCwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origCwd) }) + + home := t.TempDir() + t.Setenv("HOME", home) + projRoot := filepath.Join(home, "proj") + assert.NoError(t, os.MkdirAll(projRoot, 0o755)) + assert.NoError(t, os.Chdir(projRoot)) + assert.Equal(t, ExitOK, Run([]string{"config", "init", "--project"})) + path := filepath.Join(projRoot, ".config", "mysql-cli", "config.toml") + _, err := os.Stat(path) + assert.NoError(t, err, "config file should exist at %s", path) + b, err := os.ReadFile(path) + assert.NoError(t, err) + assert.Contains(t, string(b), "default", "template should contain 'default'") +} + +// TestConfigInit_DoesNotOverwrite verifies the --force gate: without --force an +// existing config is left EXACTLY untouched (exact-equality assertion, not +// substring - so any byte change would fail); with --force the file is replaced +// by the template (content no longer equals "# existing"). +func TestConfigInit_DoesNotOverwrite(t *testing.T) { + origCwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(origCwd) }) + + home := t.TempDir() + t.Setenv("HOME", home) + gp := filepath.Join(home, ".config", "mysql-cli", "config.toml") + assert.NoError(t, os.MkdirAll(filepath.Dir(gp), 0o755)) + assert.NoError(t, os.WriteFile(gp, []byte("# existing"), 0o600)) + // without --force -> non-zero exit, file EXACTLY unchanged + code := Run([]string{"config", "init", "--global"}) + assert.NotEqual(t, ExitOK, code) + b, err := os.ReadFile(gp) + assert.NoError(t, err) + assert.Equal(t, "# existing", string(b)) // exact equality, NOT substring + // with --force -> overwritten, content no longer "# existing" + assert.Equal(t, ExitOK, Run([]string{"config", "init", "--global", "--force"})) + b2, err := os.ReadFile(gp) + assert.NoError(t, err) + assert.NotEqual(t, "# existing", string(b2)) +} + // TestConfigShow_UnknownDatasource verifies the error path for an unknown name. func TestConfigShow_UnknownDatasource(t *testing.T) { origCwd, _ := os.Getwd() diff --git a/internal/config/loader.go b/internal/config/loader.go index 2dfbde0..ced20ad 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -8,23 +8,23 @@ import ( "strings" ) -// relConfigPath is the shared relative path for both global and project configs. -const relConfigPath = ".config/mysql-cli/config.toml" +// RelConfigPath is the shared relative path for both global and project configs. +const RelConfigPath = ".config/mysql-cli/config.toml" // DiscoverProject walks up from start looking for .config/mysql-cli/config.toml. -// Returns (projectRoot, configPath, found). projectRoot strips the relConfigPath +// Returns (projectRoot, configPath, found). projectRoot strips the RelConfigPath // suffix (it is the dir containing .config/, NOT .config/mysql-cli/ itself). // Stops when reaching home or the filesystem root. func DiscoverProject(start, home string) (root, configPath string, found bool) { dir := start for { // stop at home boundary FIRST (home is never a project root): project - // and global configs share relConfigPath, so checking home's candidate + // and global configs share RelConfigPath, so checking home's candidate // before the boundary would wrongly treat the global config as a project. if dir == home || dir == filepath.Dir(dir) { return "", "", false } - candidate := filepath.Join(dir, relConfigPath) + candidate := filepath.Join(dir, RelConfigPath) if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return dir, candidate, true } @@ -54,7 +54,7 @@ type LoadOpts struct { } // globalConfigPath returns /.config/mysql-cli/config.toml. -func globalConfigPath(home string) string { return filepath.Join(home, relConfigPath) } +func globalConfigPath(home string) string { return filepath.Join(home, RelConfigPath) } // ResolvePathChain returns the diagnostic view of all discovered entries // (including an untrusted project entry marked Trusted=false), ordered low->high. @@ -145,7 +145,7 @@ func MergeConfigs(low, high *Config) *Config { // TrustFilePath returns /.config/mysql-cli/trusted. func TrustFilePath(home string) string { - return filepath.Join(home, relConfigPath[:len(relConfigPath)-len("config.toml")]+"trusted") + return filepath.Join(home, RelConfigPath[:len(RelConfigPath)-len("config.toml")]+"trusted") } // ReadTrusted parses the plaintext trust file (one normalized path per line). diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index d035cee..8c04692 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -49,7 +49,7 @@ func TestDiscoverProject_StopsAtHome(t *testing.T) { func TestDiscoverProject_HomeGlobalConfigIsNotProject(t *testing.T) { home := t.TempDir() // global config lives at home (shared relative path) - must NOT be treated as a project - makeProjectTree(t, home, relConfigPath) + makeProjectTree(t, home, RelConfigPath) cwd := filepath.Join(home, "sub") assert.NoError(t, os.MkdirAll(cwd, 0o755)) _, _, found := DiscoverProject(cwd, home) @@ -147,7 +147,7 @@ host = "hb" func TestLoad_ProjectTrustedMergedOverGlobal(t *testing.T) { home := t.TempDir() - globalPath := filepath.Join(home, relConfigPath) + globalPath := filepath.Join(home, RelConfigPath) writeCfgAt(t, globalPath, `default = "g" [datasource.g] host = "gh" @@ -155,7 +155,7 @@ host = "gh" host = "sh" `) projRoot := filepath.Join(home, "proj") - projPath := filepath.Join(projRoot, relConfigPath) + projPath := filepath.Join(projRoot, RelConfigPath) writeCfgAt(t, projPath, `default = "p" [datasource.p] host = "ph" @@ -178,12 +178,12 @@ host = "projsh" func TestLoad_ProjectUntrustedFallsBackToGlobal(t *testing.T) { home := t.TempDir() - globalPath := filepath.Join(home, relConfigPath) + globalPath := filepath.Join(home, RelConfigPath) writeCfgAt(t, globalPath, `[datasource.g] host = "gh" `) projRoot := filepath.Join(home, "proj") - writeCfgAt(t, filepath.Join(projRoot, relConfigPath), `[datasource.p] + writeCfgAt(t, filepath.Join(projRoot, RelConfigPath), `[datasource.p] host = "ph" `) cfg, entries, err := Load(LoadOpts{ @@ -264,12 +264,12 @@ func TestReadTrusted_NoFileReturnsEmpty(t *testing.T) { // Default IsTrusted (nil) uses the real trust file at Home. func TestLoad_DefaultIsTrustedUsesTrustFile(t *testing.T) { home := t.TempDir() - globalPath := filepath.Join(home, relConfigPath) + globalPath := filepath.Join(home, RelConfigPath) writeCfgAt(t, globalPath, `[datasource.g] host = "gh" `) projRoot := filepath.Join(home, "proj") - writeCfgAt(t, filepath.Join(projRoot, relConfigPath), `[datasource.p] + writeCfgAt(t, filepath.Join(projRoot, RelConfigPath), `[datasource.p] host = "ph" `) // not trusted yet -> project skipped From de1e2e317b643037bb672156fdbbe8de55da9a07 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 17:58:40 +0800 Subject: [PATCH 27/29] docs(skill): document project-level config + trust + config subcommands --- skills/mysql-shared/SKILL.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/skills/mysql-shared/SKILL.md b/skills/mysql-shared/SKILL.md index 0013a2a..3a1189c 100644 --- a/skills/mysql-shared/SKILL.md +++ b/skills/mysql-shared/SKILL.md @@ -1,6 +1,6 @@ --- name: mysql-shared -version: 1.1.0 +version: 1.2.0 description: > mysql-cli 共享规则:配置与数据源、全局 flag、安全模型、稳定退出码、错误自修复、输出格式。 使用 mysql-query 或 mysql-schema 技能前 MUST 先用 Read 加载本技能。也在用户询问 @@ -52,11 +52,10 @@ ls ~/.config/mysql-cli/config.toml If missing, `mysql-cli` still works via `MYSQL_*` env vars or `--host/--port/...` overrides, but a config file is the normal path. Resolution priority is -**CLI flag > env > file > default**. Passwords support `${ENV}` placeholders. +**CLI flag > env > project-level (trusted) > global > default** (see Project-level Config below). Passwords support `${ENV}` placeholders (expanded only in trusted configs). 若不存在,仍可通过 `MYSQL_*` 环境变量或 `--host/--port/...` 覆盖运行, -但配置文件是常规路径。解析优先级:**CLI flag > env > file > default**。 -密码支持 `${ENV}` 占位符。 +但配置文件是常规路径。解析优先级:**CLI flag > env > 项目级(已信任) > 全局 > default**(见下文「项目级配置」)。密码支持 `${ENV}` 占位符(仅在已信任配置中展开)。 ### 2. Datasource reachable / 数据源可达 @@ -79,6 +78,26 @@ If the config defines multiple `[datasource.]` profiles, select one with --- +## Project-level Config / 项目级配置 + +mysql-cli 支持项目级配置,与全局同构、覆盖式合并(类似 MCP 的 `.mcp.json`)。 + +- **项目级 config 位置**:`/.config/mysql-cli/config.toml`。从 cwd 逐级向上查找,首个即停(到 home/fs root 为止)。与全局 `~/.config/mysql-cli/config.toml` 共享相对路径 `.config/mysql-cli/config.toml`,仅根不同。 +- **信任机制(安全)**:项目级 config 默认**不加载**。首次需在项目目录下执行 `mysql-cli config trust`,把项目根写入信任清单 `~/.config/mysql-cli/trusted`。未信任时**静默回退全局**(exit 0,不报错);`${ENV}` 密码占位符仅在已信任的项目级 config 中展开--防止恶意仓库套取本地环境变量或劫持连接。 +- **优先级链**:`--config` flag > `MYSQL_CLI_CONFIG` env > 项目级(已信任) > 全局 > `MYSQL_*` 字段级覆盖 > default。`--config` 或 `MYSQL_CLI_CONFIG` 指定后只读该文件,跳过自动发现。 +- **覆盖式合并**:同名 datasource 整体替换(项目级胜,含 SSH 子表),不同名取并集;`default`/`default_limit` 项目级覆盖全局。 + +### config 子命令族 / config subcommands + +| 命令 | 作用 | +|---|---| +| `mysql-cli config path` `[-j]` | 显示生效文件链 + 信任状态(project 标 `trusted` / `untrusted, skipped` + global) | +| `mysql-cli config show` `[name]` `[-j]` | 显示合并后最终配置(密码脱敏:明文->`***`,`${ENV}` 原样) | +| `mysql-cli config trust [dir]` | 信任项目根(默认检测到的项目根),写入信任清单 | +| `mysql-cli config init [--project\|--global] [--force]` | 生成模板 config.toml | + +> **自省提示**:查询结果或连接不符合预期时,先 `mysql-cli config path` 查信任状态,再 `mysql-cli config show` 查合并后配置(密码已脱敏)。 + ## Global Flags / 全局 flag All commands share global flags: `-d/--datasource`, `-f/--format` (default From 9fbbb7b9d3c7f2675ee4df387df0871ecbf6719f Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 18:00:21 +0800 Subject: [PATCH 28/29] style(config,cli): gofmt sweep - trailing newline + Globals struct alignment --- internal/cli/commands.go | 7 ++++--- internal/cli/root.go | 34 +++++++++++++++++----------------- internal/config/loader.go | 2 +- internal/config/loader_test.go | 6 +++--- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index f074156..ba2654b 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -79,9 +79,10 @@ func (g *Globals) defaultCap() int { } // resolveCap decides (limit, probe) for a read query: -// --limit explicit -> (g.Limit, false) exact N, no probe -// --no-limit -> (0, false) no cap -// otherwise -> (defaultCap, true) default cap with cap+1 probe +// +// --limit explicit -> (g.Limit, false) exact N, no probe +// --no-limit -> (0, false) no cap +// otherwise -> (defaultCap, true) default cap with cap+1 probe func (g *Globals) resolveCap(cmd *cobra.Command) (int, bool) { if cmd.Flags().Changed("limit") { return g.Limit, false diff --git a/internal/cli/root.go b/internal/cli/root.go index ce1cf86..58147f7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -30,24 +30,24 @@ const ( // Globals carries parsed global flags shared by all subcommands. type Globals struct { - Datasource string - Format string - Write bool - DDL bool - Yes bool - Limit int - NoLimit bool - DefaultLimit int - Timeout string - ConfigPath string + Datasource string + Format string + Write bool + DDL bool + Yes bool + Limit int + NoLimit bool + DefaultLimit int + Timeout string + ConfigPath string ConfigExplicit bool // true when --config was explicitly set on the command line - Host string - Port int - User string - Password string - Database string - out io.Writer - eout io.Writer + Host string + Port int + User string + Password string + Database string + out io.Writer + eout io.Writer } // Run parses args and executes; returns the process exit code. diff --git a/internal/config/loader.go b/internal/config/loader.go index ced20ad..a2cf842 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -232,4 +232,4 @@ func Masked(ds Datasource) Datasource { out.Password = "***" } return out -} \ No newline at end of file +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 8c04692..22a074a 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -250,8 +250,8 @@ func TestIsTrusted_SymlinkNormalized(t *testing.T) { os.MkdirAll(real, 0o755) link := filepath.Join(home, "link") os.Symlink(real, link) - assert.NoError(t, AddTrust(home, link)) // add via symlink path - assert.True(t, IsTrusted(home, real)) // resolves to real -> trusted + assert.NoError(t, AddTrust(home, link)) // add via symlink path + assert.True(t, IsTrusted(home, real)) // resolves to real -> trusted } func TestReadTrusted_NoFileReturnsEmpty(t *testing.T) { @@ -298,4 +298,4 @@ func TestMasked_EnvPlaceholderKept(t *testing.T) { func TestMasked_EmptyStaysEmpty(t *testing.T) { out := Masked(Datasource{Password: ""}) assert.Equal(t, "", out.Password) -} \ No newline at end of file +} From b9287f390afc04a2cb3dd668e82309da2588b9d4 Mon Sep 17 00:00:00 2001 From: AllenJ Date: Fri, 24 Jul 2026 18:05:53 +0800 Subject: [PATCH 29/29] docs(config): fix MergeConfigs godoc placement + drop stale Phase 1 comment --- internal/config/loader.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/config/loader.go b/internal/config/loader.go index a2cf842..a6b98b1 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -32,10 +32,6 @@ func DiscoverProject(start, home string) (root, configPath string, found bool) { } } -// MergeConfigs overlays high onto low using覆盖式 (override) semantics: -// same-name datasource is replaced wholesale (including SSH subtable), -// distinct names are unioned, Default/DefaultLimit override when non-zero/non-empty. -// high==nil returns low unchanged (nil-safe). // PathEntry is one resolved config file in the chain (diagnostic view). type PathEntry struct { Path string // absolute config file path @@ -50,7 +46,7 @@ type LoadOpts struct { EnvConfig string // MYSQL_CLI_CONFIG value ("" if unset) Cwd string // project discovery start dir Home string // home dir: global config + trust store - IsTrusted func(projectRoot string) bool // injectable; nil -> always false (Phase 1) + IsTrusted func(projectRoot string) bool // injectable; nil -> use the real trust store at Home } // globalConfigPath returns /.config/mysql-cli/config.toml. @@ -116,6 +112,10 @@ func Load(opts LoadOpts) (*Config, []PathEntry, error) { return merged, entries, nil } +// MergeConfigs overlays high onto low using 覆盖式 (override) semantics: +// same-name datasource is replaced wholesale (including SSH subtable), +// distinct names are unioned, Default/DefaultLimit override when non-zero/non-empty. +// high==nil returns low unchanged (nil-safe). func MergeConfigs(low, high *Config) *Config { if high == nil { return low