将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。 同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
206 lines
8.1 KiB
Python
206 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
||
"""muse-example 唯一数据库通道(access-database Skill 脚本层)。
|
||
|
||
- query 卡片式打印=审查面;exec 报影响行数;apply 整文件一个事务失败全回滚。
|
||
- 失败原样抛错不静默(公约)。
|
||
|
||
连接本身归共享模块 `muse_db`:本文件只提供 CLI,不再作为其它 Skill 的 import 目标。
|
||
"""
|
||
import json
|
||
import sys
|
||
|
||
import click
|
||
import psycopg
|
||
from muse_db import connect
|
||
from psycopg import sql
|
||
|
||
TRUNC = 160 # 卡片模式长值截断阈值(字符)
|
||
|
||
# exec / execparams 只允许这些写语句;DDL 与管理类命令一律机械拒绝。
|
||
_WRITE_ALLOWED = ("INSERT", "UPDATE", "DELETE", "WITH")
|
||
_DDL_OR_ADMIN = frozenset({
|
||
"CREATE", "ALTER", "DROP", "TRUNCATE", "GRANT", "REVOKE", "COMMENT",
|
||
"VACUUM", "ANALYZE", "REINDEX", "CLUSTER", "REFRESH", "SECURITY",
|
||
"CALL", "DO", "COPY", "LISTEN", "NOTIFY", "LOAD", "DISCARD", "SET",
|
||
})
|
||
|
||
|
||
def _fmt(value, full: bool) -> str:
|
||
"""卡片值格式化:NULL 显示 ∅;长值默认截断并标注总长。"""
|
||
if value is None:
|
||
return "∅"
|
||
s = str(value)
|
||
if not full and len(s) > TRUNC:
|
||
return s[:TRUNC] + f"…(共{len(s)}字)"
|
||
return s
|
||
|
||
|
||
def _first_keyword(sql_text: str) -> str:
|
||
stripped = sql_text.strip()
|
||
return stripped.split(None, 1)[0].upper() if stripped else ""
|
||
|
||
|
||
def _require_single_statement(sql_text: str, cmd: str) -> str:
|
||
stripped = sql_text.strip()
|
||
if not stripped:
|
||
raise click.ClickException(f"{cmd} 拒绝空语句")
|
||
if ";" in stripped.rstrip(";"):
|
||
raise click.ClickException(f"{cmd} 只接受单条语句(禁止多语句)")
|
||
return stripped
|
||
|
||
|
||
def _require_delete_has_where(sql_text: str, cmd: str) -> None:
|
||
"""裸 DELETE(无 WHERE)会整表清空,红线禁止。"""
|
||
if _first_keyword(sql_text) != "DELETE":
|
||
return
|
||
if " WHERE " not in sql_text.upper():
|
||
raise click.ClickException(f"{cmd} 禁止无 WHERE 的 DELETE(裸删)")
|
||
|
||
|
||
def _guard_exec(sql_text: str) -> None:
|
||
"""exec:拒绝 DDL/管理语句与裸 DELETE;只放行 INSERT/UPDATE/DELETE/WITH。"""
|
||
stripped = _require_single_statement(sql_text, "exec")
|
||
first = _first_keyword(stripped)
|
||
if first in _DDL_OR_ADMIN or first not in _WRITE_ALLOWED:
|
||
raise click.ClickException(
|
||
f"exec 禁止 DDL/非写白名单语句(收到 {first or '空'});"
|
||
"只允许 INSERT/UPDATE/DELETE/WITH"
|
||
)
|
||
_require_delete_has_where(stripped, "exec")
|
||
|
||
|
||
@click.group()
|
||
def cli():
|
||
"""muse-example 数据库通道"""
|
||
|
||
|
||
@cli.command()
|
||
@click.argument("sql_text")
|
||
@click.option("--json", "as_json", is_flag=True, help="按 JSON 数组输出(给脚本消费)")
|
||
@click.option("--full", is_flag=True, help="长值不截断")
|
||
@click.option("--max", "max_rows", default=50, show_default=True, help="最多打印行数")
|
||
def query(sql_text: str, as_json: bool, full: bool, max_rows: int):
|
||
"""执行 SELECT,卡片式打印(审查面)。连接只读,写语句(含 INSERT...RETURNING)会被 PG 拒。"""
|
||
with connect(readonly=True) as conn:
|
||
cur = conn.execute(sql_text)
|
||
if cur.description is None:
|
||
raise click.ClickException("该语句无结果集——写操作请用 exec")
|
||
cols = [d.name for d in cur.description]
|
||
rows = cur.fetchall()
|
||
if as_json:
|
||
out = [dict(zip(cols, r)) for r in rows[:max_rows]]
|
||
click.echo(json.dumps(out, ensure_ascii=False, default=str, indent=1))
|
||
else:
|
||
width = max((len(c) for c in cols), default=0)
|
||
for i, r in enumerate(rows[:max_rows], 1):
|
||
click.echo(f"── 行 {i} " + "─" * 30)
|
||
for c, v in zip(cols, r):
|
||
click.echo(f"{c:<{width}} │ {_fmt(v, full)}")
|
||
if len(rows) > max_rows:
|
||
click.echo(f"…(共 {len(rows)} 行,仅示 {max_rows} 行,--max 放宽)")
|
||
else:
|
||
click.echo(f"(共 {len(rows)} 行)")
|
||
|
||
|
||
@cli.command("exec")
|
||
@click.argument("sql_text")
|
||
def exec_(sql_text: str):
|
||
"""执行单条写操作(INSERT/UPDATE/DELETE,须带 WHERE),报影响行数。禁止 DDL。"""
|
||
_guard_exec(sql_text)
|
||
with connect() as conn:
|
||
cur = conn.execute(sql_text)
|
||
conn.commit()
|
||
n = cur.rowcount
|
||
click.echo(f"OK(影响 {n} 行)" if n >= 0 else "OK")
|
||
|
||
|
||
def _read_params(stdin_json: bool, param_tuple: tuple, stream=None) -> list:
|
||
"""收集参数:--stdin 时从 stream(默认 stdin)读 JSON 数组;否则用 --param 按序。两者互斥。
|
||
|
||
大对象(raw 全文)走 --stdin:内容经 JSON 数组从标准输入流入,避开 shell 转义与 ARG_MAX。
|
||
"""
|
||
if stdin_json:
|
||
if param_tuple:
|
||
raise click.ClickException("--stdin 与 --param 互斥,二选一")
|
||
raw = (stream if stream is not None else sys.stdin).read()
|
||
try:
|
||
data = json.loads(raw)
|
||
except json.JSONDecodeError as e:
|
||
raise click.ClickException(f"--stdin 参数不是合法 JSON:{e}")
|
||
if not isinstance(data, list):
|
||
raise click.ClickException("--stdin 参数必须是 JSON 数组,元素按 %s 顺序排列")
|
||
return data
|
||
return list(param_tuple)
|
||
|
||
|
||
# execparams 只允许这些种类的单条语句(参数化写);拒绝 DO/CALL/DDL/TRUNCATE/多语句
|
||
_EXECPARAMS_ALLOWED = _WRITE_ALLOWED
|
||
|
||
|
||
def _guard_execparams(sql_text: str) -> None:
|
||
stripped = _require_single_statement(sql_text, "execparams")
|
||
first = _first_keyword(stripped)
|
||
if first not in _EXECPARAMS_ALLOWED:
|
||
raise click.ClickException(
|
||
f"execparams 只做参数化写(INSERT/UPDATE/DELETE/WITH),收到 {first or '空'} 语句")
|
||
_require_delete_has_where(stripped, "execparams")
|
||
|
||
|
||
@cli.command("execparams")
|
||
@click.argument("sql_text")
|
||
@click.option("--stdin", "stdin_json", is_flag=True,
|
||
help="参数从 stdin 读入(JSON 数组,按 %s 顺序);大对象用,避开 shell 转义与 ARG_MAX")
|
||
@click.option("--param", "param_tuple", multiple=True,
|
||
help="按序传参(小值,可重复);与 --stdin 互斥")
|
||
def execparams(sql_text: str, stdin_json: bool, param_tuple: tuple):
|
||
"""参数化写操作:SQL 用 %s 占位,参数走服务端绑定(防注入;大内容不拼命令行)。"""
|
||
_guard_execparams(sql_text)
|
||
params = _read_params(stdin_json, param_tuple)
|
||
with connect() as conn:
|
||
cur = conn.execute(sql_text, params)
|
||
if cur.description is not None:
|
||
raise click.ClickException("该语句有结果集——查询请用 query(execparams 只做写)")
|
||
conn.commit()
|
||
n = cur.rowcount
|
||
click.echo(f"OK(影响 {n} 行)" if n >= 0 else "OK")
|
||
|
||
|
||
@cli.command()
|
||
@click.argument("file", type=click.Path(exists=True))
|
||
def apply(file: str):
|
||
"""应用 SQL 文件:整文件一个事务(无参数走 simple protocol 支持多语句),失败全回滚。"""
|
||
with open(file, encoding="utf-8") as f:
|
||
sql = f.read()
|
||
with connect() as conn:
|
||
conn.execute(sql)
|
||
conn.commit()
|
||
click.echo(f"已应用: {file}")
|
||
|
||
|
||
@cli.command()
|
||
def tables():
|
||
"""表清单 + 活行数(有 deleted 列的表计 deleted=FALSE,其余计全行)。"""
|
||
with connect() as conn:
|
||
names = [r[0] for r in conn.execute(
|
||
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename")]
|
||
for t in names:
|
||
has_deleted = conn.execute(
|
||
"SELECT 1 FROM information_schema.columns WHERE table_name=%s AND column_name='deleted'",
|
||
(t,)).fetchone()
|
||
# 表名用 sql.Identifier 安全拼接(不手工拼双引号),避免恶意表名破坏语句
|
||
if has_deleted:
|
||
stmt = sql.SQL("SELECT count(*) FROM {} WHERE deleted = FALSE").format(sql.Identifier(t))
|
||
else:
|
||
stmt = sql.SQL("SELECT count(*) FROM {}").format(sql.Identifier(t))
|
||
n = conn.execute(stmt).fetchone()[0]
|
||
click.echo(f"{t:<45} {n}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
cli()
|
||
except psycopg.Error as e:
|
||
# 数据库错误原样透出(可追踪、不静默)
|
||
click.echo(f"[db错误] {type(e).__name__}: {e}", err=True)
|
||
sys.exit(1)
|