跳到主要内容

Agent 驱动的 CLI 与 skills 下发

30 秒导读: Wren 的核心野心不是「给人一个 SQL 工具」,而是「把自己做成一个给 AI agent 用的工具」。 这一章讲这条设计主线:一个 typer 写的 wren 命令,把所有能力挂成子命令树;再在其上架两个专门的 「agent 接口」——(a) wren skills get 把工作流指南按需、随版本下发给 agent;(b) wren ask 把用户的自然语言问题包一层 prompt 交给 agent,自己从不执行。看完你会明白 Wren 为什么把「内容」 塞进 CLI 而不写死进 agent。

本章只讲 CLI 装配agent 交互层。MDL 语义模型、引擎治理、记忆检索、GenBI 的内部实现分别属于 02 / 03 / 04 / 05,这里只讲它们怎么被挂上来、agent 怎么进入它们,不展开。


1. 这是什么(零基础也能懂)

一句话定义: wren 是一个命令行程序,但它的真正用户不是坐在终端前的人,而是替人干活的 AI agent

先建立一个直觉。一般 CLI 是「人打命令 → 机器执行」。Wren 反过来设计:它假设一个 LLM agent (Claude Code、Cursor、某个 MCP 客户端……)会去调 wren,所以它不仅提供「执行」类命令,还提供两类 专门喂给 agent 的东西:

  • 工作流指南(skills)——"要连一个新数据库,按这几步走"这种 markdown 剧本,agent 读了才知道怎么编排多步任务。
  • prompt 塑形(ask)——把用户一句"上个月营收多少"包装成一段结构化 prompt,让 agent 有章可循。

给谁用 / 解决什么问题: 设想你在用一个 AI 编码助手,对它说"帮我把 Stripe 的数据接进来做分析"。 agent 本身不知道 Wren 的具体步骤。于是它先跑 wren skills get dlt-connector,拿到一份分四阶段的操作 指南,再照着一步步调 wren 的其它子命令。Wren 把「怎么做」的知识,做成了 CLI 能吐出来的内容。

用起来什么样: 一个 agent 的最小交互序列,直观感受一下——

# 1) agent 先问 Wren:你有哪些工作流指南?
wren skills list
# 2) 用户想上手 → 取 onboarding 指南,照着走
wren skills get onboarding
# 3) 用户问了个数据问题 → 把它包成一段 agent 能执行的 prompt
wren ask "上个月每个地区的营收是多少" --direct
# 4) agent 照包好的流程,最终执行一条走语义层的查询
wren --sql "SELECT region, SUM(amount) FROM orders GROUP BY region"

一句话直觉/类比:wren 想成一家餐厅的后厨对讲系统。人类顾客(用户)提需求;服务员(agent) 不用背整本菜谱,而是随时对着对讲机问后厨"这道菜怎么做"(skills get),后厨把当前版本的做法念给他听。 菜谱印在后厨墙上(随 wheel 走),不是抄在服务员口袋里(不写死进 agent)——所以厨房换了配方,服务员立刻同步。

本节不出现底层代码。目标:明白「这是一个把自己做成 agent 工具的 CLI」。


2. 顶层全景(它大概怎么转)

Wren 的 CLI 是一个 typer app —— app = typer.Typer(name="wren") (core/wren/src/wren/cli.py:14),下面挂了两类东西:

  1. 一个默认命令 + 直接执行类子命令(query / dry-run / dry-plan)——真正打数据库。
  2. 一堆子命令组(sub-typer)——每组是一个独立文件里的 typer.Typer(),用 app.add_typer(...) 挂上来。

怎么读下面这张图: 从上往下是「一个根 app 装配一切」;右侧标了每个部件所在文件。带 ★ 的两支是本章重点的 agent 接口,其余子命令组只讲「怎么挂」,内部实现留给后续章节。

wren (typer app) cli.py:14 app = Typer(name="wren")

┌────────────────────────────┼───────────────────────────────────────┐
│ 默认命令 & 直接执行 │ 子命令组 (add_typer / command 挂载) │
│ cli.py:355 main() │ cli.py:594-623 │
│ --sql 即查询 │ │
│ query / dry-run / dry-plan │ │
└────────────────────────────┘ │
│ │
┌───────────┬───────────┼───────────┬───────────┬────────────┐ │
▼ ▼ ▼ ▼ ▼ ▼ │
★ skills ★ ask context docs genbi memory
skills_cli ask_cli context_cli docs_cli genbi/cli memory/cli
下发指南 塑形 prompt MDL 项目 连接字段文档 仪表盘生成 记忆检索
→本章 §4 →本章 §5 →ch.02 →(文档生成) →ch.05 →ch.04
(还有 cube / utils / profile 三组,同样 add_typer 挂上)

部件一句话职责:

子命令组干什么挂载点内部实现在哪
(默认) / query / dry-run / dry-plan走 MDL 语义层执行/校验 SQLcli.py:355 main / cli.py:428 querych.03
skills把 bundled 工作流指南下发给 agentcli.py:608 add_typer(skills_app)本章 §4
ask把用户 prompt 包成 agent 用的 promptcli.py:603 command("ask")本章 §5
contextYAML MDL 项目生命周期(init/build/validate/show)cli.py:605 add_typer(context_app)ch.02
docs生成各数据源的连接字段文档cli.py:596 add_typer(docs_app)本章简述
genbi从语义层生成并部署仪表盘 appcli.py:622 add_typer(genbi_app)ch.05
memoryschema 上下文 + 召回过往查询cli.py:617 add_typer(memory_app)ch.04
cube / utils / profile度量、类型工具、连接 profilecli.py:599-623 add_typer(...)(相关章节)

主线走一遍(高层): agent 拿到用户请求 → 若是探索类,先 wren skills list / skills get <name> 拿指南(§4)→ 若是数据问题,可选 wren ask 把问题塑形(§5)→ 照指南编排:context show 看模型、 memory recall 找相似历史、最后 wren query 执行 → 用自然语言回答。


3. CLI 是怎么装配起来的(装配,不展开实现)

这节讲一个根 typer app 如何把散在各文件里的子命令组合起来。这是理解「agent 接口挂在哪」的地基。

3.1 根 app 与两条挂载路径

cli.py 顶部先建根 app,再在文件末尾统一 import 各子 app 并挂载 (core/wren/src/wren/cli.py:594-623)。typer 有两种挂法,Wren 两种都用:

  • app.add_typer(sub_app)——把一个命令组整体挂上(wren context …wren skills …)。 绝大多数子命令用这种。
  • app.command(name="ask")(fn)——把单个函数挂成一个平级命令。ask 特殊,它只有一个动作, 于是从 ask_cli import 出 ask 函数、直接注册成 wren ask(cli.py:598 import、cli.py:603 注册), 不套一层 ask 命令组。

一段示意,帮你看清「装配」长什么样(示意,非源码):

# 示意,非源码:根 app 把各文件里的 sub-typer 收拢到一起
app = typer.Typer(name="wren") # 根

from wren.skills_cli import skills_app # 每个子命令组是独立文件里的 Typer()
app.add_typer(skills_app) # 挂成 `wren skills …`

from wren.ask_cli import ask as _ask # ask 只有一个动作
app.command(name="ask")(_ask) # 直接挂成平级的 `wren ask`

真实装配见 cli.py:594-623:docs_appcontext_appcube_apputils_appskills_appmemory_appgenbi_appprofile_app 逐个 add_typer,askcommand(name="ask") 注册。

3.2 默认命令:不带子命令时就是 query

wren --sql "…" 不写任何子命令也能查询。靠的是回调上的 @app.callback(invoke_without_command=True)(core/wren/src/wren/cli.py:355 main):

  • ctx.invoked_subcommand 非空 → 交给对应子命令,main 直接 return(cli.py:410-411)。
  • 否则,有 --sql 就执行查询,没有就打印 help(cli.py:412-422)。

这让「查询」成为默认动作,同时保留 wren query 显式子命令(cli.py:428 query)给脚本用。

3.3 两个跨命令共享的约定:_require_mdl 与 _DEFAULT_CONN

装配层还统一了「上下文从哪来」——agent 少填参数的关键。

  • _require_mdl(mdl)(core/wren/src/wren/cli.py:23):没显式给 --mdl 时, 自动发现项目根、取 <project>/target/mdl.json;缺了就提示 run \wren context build` first。 于是 agent 在项目目录里直接 wren --sql …` 即可,不必每次指路。MDL 是什么、怎么 build → 见 ch.02。
  • _DEFAULT_CONN = _WREN_HOME / "connection_info.json"(cli.py:16-17): 连接信息默认落在 ~/.wren/。没给 --connection-* 时从这里(或 profile)自动读 (_load_conn cli.py:78)。

为什么对 agent 重要: 这两个默认值把「MDL 路径」「连接信息」从每条命令的必填参数里拿掉了。 agent 只要在对的目录、事先建好 profile,就能直接执行——少一个要它猜的参数,就少一处出错。 连接/profile 的解析细节属于引擎章,这里只点明「装配层替 agent 兜了底」。


4. Agent 接口(a):skills 按需下发

这是本章第一个核心机制。要解决的小问题:agent 怎么知道一个多步工作流该怎么走?

4.1 思路:内容随版本走,不写死进 agent

朴素做法是把工作流写进 agent 的 system prompt,或做成一个静态 skill 文件缓存在 agent 侧。Wren 拒绝这么做, 理由一句话:agent 侧的缓存会和 CLI 版本漂移。今天 wren-engine 升级、命令变了,agent 口袋里那份旧指南 就错了。

Wren 的解法:把指南塞进 wheel,用 CLI 现取现给。指南是 package data,随 pip install wrenai 的版本 一起分发;agent 每次 wren skills get <name> 都拿到和当前 CLI 完全匹配的那一份。skill 里也反复强调这点—— "served by the wren CLI, so it always matches your installed wren-engine version"(见 usage/SKILL.md 抬头)。

4.2 内容长在哪:skills_content/ 六份指南

指南以纯 markdown 存在 wheel 内 core/wren/src/wren/skills_content/<name>/SKILL.md。当前六份,各司一段工作流:

skill 名覆盖的工作流角色一句话
onboarding环境检查 → 项目脚手架 → .env 连接 → 首查端到端把用户领进门,"one step per turn、绝不在 chat 里要凭据"
usage日常问数:取 schema → 召回历史 → 写 SQL → 执行 → 学习数据问题的主流程指南(带 memory.md / wren-sql.md 参考)
generate-mdl探库 → 类型归一 → 生成 MDL YAML从既有数据库反建语义项目
dlt-connector用 dlt 拉 SaaS 数据入 DuckDB → 反建 Wren 项目 → 验证接 HubSpot/Stripe/… 等 SaaS(带 introspect_dlt.py 脚本)
enrich-contextraw/ 手册/术语表 → 补 DB schema 装不下的业务语义(单位、枚举、cube)填「业务上下文」缺口,grill/auto-pilot 两模式
genbigenbi build 出指令 → agent 写 app → register/verify/deploy把语义层做成可分享仪表盘(→ ch.05)

关键设计:指南只「指路」,不「抄录」。例如 onboarding/SKILL.md 明确说 "per-datasource setup notes, complete troubleshooting playbook live in the docs, not here"——它的职责是强制 agent 侧规则 (一步一回合、绝不在 chat 里要凭据)并把 agent 派发到正确的 doc 或兄弟 skill,而不是把所有细节堆进自己。 这样一份 SKILL.md 保持精悍,细节各归其位。

4.3 真实实现:从 wheel 里读文件

下发逻辑集中在 core/wren/src/wren/skills_delivery.py,极简:

  • 定位内容根: _content_root()(skills_delivery.py:35)用 importlib.resources.files("wren") / "skills_content" 拿到 wheel 内目录的 traversable。 用 importlib.resources 而非硬编码文件路径,是因为装进 wheel/zip 后也能读——这正是"随包分发"的技术前提。
  • 取一份指南: get_skill(name, full=False)(skills_delivery.py:48)读 <name>/SKILL.md 返回; --full 时把 references/*.md 按文件名排序拼在后面,各加一个分隔标题(skills_delivery.py:57-69)。
  • 列全部: list_skills()(skills_delivery.py:82)遍历目录,每个有 SKILL.md 的算一个, 用 frontmatter 的 description 首句做 summary(_summary skills_delivery.py:103)。
  • 取脚本: get_script(name, script)(skills_delivery.py:72)从 <name>/scripts/ 找同名脚本 (如 dlt-connectorintrospect_dlt)。
  • 数据结构 SkillInfo(skills_delivery.py:27)只装 name / summary / references / scripts 四个字段, 是 list 输出的载体。

CLI 侧 skills_cli.py 只是薄薄一层 typer 包装:wren skills list(skills_cli.py:17 list_cmd)、 wren skills get <name> [--full] [--script …](skills_cli.py:36 get),把上面几个函数的结果 typer.echo 到 stdout,找不到就报 unknown skill(skills_cli.py:52-58)。

一段示意,把「取指南」的核心想法演出来(示意,非源码):

# 示意,非源码:skills get 的骨架
def get_skill(name, full=False):
root = resources.files("wren") / "skills_content" # wheel 内目录
skill = root / name
text = (skill / "SKILL.md").read_text() # 主指南
if not full:
return text # 默认只给主指南
refs = sorted((skill / "references").glob("*.md")) # --full 追加参考
return text + "".join(f"\n---\n{r.read_text()}" for r in refs)

4.4 外部发现桩:agent 一开始怎么知道有 wren skills

有个先有鸡还是先有蛋的问题:指南在 CLI 里,可 agent 一开始并不知道该去调 CLI。Wren 用一个约 50 行的 discovery stub 解决——放在仓库 skills/ 下,给 agent 平台去发现:

  • skills/wren/SKILL.md——一个标准 skill 文件,但正文只教一件事:"真正的指南在 CLI 里,去调 wren skills list / wren skills get <name> / wren ask …"。它的 frontmatter description 塞满触发词 (install wren、connect database、generate mdl、build a dashboard……),让 agent 平台能在合适时机命中它。
  • skills/index.json——skill bundle 的清单(约 23 行),同样自我定位为 "Discovery stub … The actual workflow guides and prompt helpers live inside the wren CLI"。

stub 里最能说明设计意图的一句原话:

This is a discovery stub. The actual workflow guides and prompt helpers
live inside the `wren` CLI itself, so they always match the installed
wren-engine version (no skill cache, no version drift).

两层结构一句话: 外层 stub(装进 agent 平台,只负责"被发现 + 指路进 CLI",小而稳、极少改动)+ 内层 bundled 指南(装进 wheel,承载会随版本变的真实工作流)。易变的内容随版本走,稳定的入口才进 agent。

agent 平台发现 skills/wren/SKILL.md ── 50 行桩,教一句话:去调 `wren skills …`
│ (frontmatter 全是触发词,负责被命中)

wren skills list ─► wren skills get <name> ─► 拿到随版本走的完整指南
│ skills_content/<name>/SKILL.md

照指南编排:context show / memory recall / query …

5. Agent 接口(b):ask 提示塑形

第二个核心机制。要解决的小问题:用户丢来一句自然语言,怎么变成 agent 能稳定照做的 prompt?

5.1 思路:只塑形,不执行

wren ask 做的事非常克制:把用户那句话套进一个模板,渲染出一段 prompt,typer.echo 到 stdout, 到此为止。它自己从不连数据库、不跑查询——生成的 prompt 交给上游 agent 去消费执行。 文件抬头写得很直白:"It does not execute any query — it produces a prompt for an agent to consume" (core/wren/src/wren/ask.py:4-6)。

这条边界很重要:ask纯函数式的 prompt 工厂,职责单一,好测、无副作用。

5.2 两种模式:guided 给弱模型,direct 给强模型

同一个问题,包法有两种(MODES = ("guided", "direct"),ask.py:19),对应两个模板文件 core/wren/src/wren/ask_templates/{guided,direct}.md.tmpl:

模式给谁包法模板大小
guided较弱的 LLM前置一套严格任务流:分 A(数据问题)/B(探索项目)两类,每类列出编号步骤(context show → memory recall → 写 SQL → dry-plan → query),外加约束(用模型名、别编列名、别在 chat 里要凭据)~805 字节
direct较强的 LLM最小包裹:一句"你有 Wren CLI,跑 wren skills list / wren --help 自己发现能力"+ 用户问题~150 字节

直觉:弱模型需要手把手的清单才不跑偏;强模型给太多步骤反而束手束脚,给它工具入口和目标即可,让它自己规划。 direct.md.tmpl 全文只有三行,把这份"信任"体现得很清楚。

5.3 真实实现:一次字符串替换

塑形逻辑就一个函数 render(mode, user_prompt)(core/wren/src/wren/ask.py:26):校验 mode 合法 (不在 MODES 里抛 UnknownAskModeError,ask.py:22),读对应 .md.tmpl,把占位符 <USER_PROMPT>(ask.py:17)替换成用户问题,返回。同样用 importlib.resources 从 wheel 内读模板, 和 skills 一样随版本走

CLI 侧 ask_cli.pyask 函数(core/wren/src/wren/ask_cli.py:15)强制二选一:--guided--direct 必须恰好给一个,两个都给或都不给就报错退出(ask_cli.py:34-40)。为什么不设默认? ask_cli.py:2-6 的注释给了理由:两种模式包法差别巨大,一个静默的默认值会在升级时悄悄改变 agent 行为—— 所以逼调用方显式选。这和 §4 里"no version drift"是同一种洁癖:凡是会改变 agent 行为的,都不许悄悄发生。

一段示意(示意,非源码):

# 示意,非源码:ask 只做「选模板 + 填空」,不执行任何查询
def render(mode, user_prompt):
assert mode in ("guided", "direct") # 否则 UnknownAskModeError
tpl = read_template(f"{mode}.md.tmpl") # 从 wheel 内读,随版本走
return tpl.replace("<USER_PROMPT>", user_prompt) # 一次替换,返回 prompt 字符串

6. 端到端:一个 agent 怎么把这些串起来

把 §4、§5 和执行类命令连成一条真实路径。场景:用户对一个装了 Wren skill 的 AI 助手说 "帮我把这个 Postgres 库接进来,然后看看上月各地区营收"。

用户: "接上我的 Postgres,再看上月各地区营收"


① 平台命中 skills/wren/SKILL.md(触发词 connect database / data question)
│ → 桩告诉 agent:去调 wren CLI

② wren skills list # agent 看有哪些工作流
wren skills get onboarding # 取端到端上手指南
│ → 指南规则:一步一回合、绝不在 chat 里要凭据(填 .env)

③ 照 onboarding 指南编排(细节见 ch.02):
wren context init # 脚手架
wren docs connection-info postgres# 看真实连接字段(别编)
wren context build # 生成 target/mdl.json(_require_mdl 之后自动找它)

④ 到了「问数据」这步 → 把用户问题塑形
wren ask "上月各地区营收" --direct # 输出一段 prompt(不执行)
│ → agent 按 prompt 规划:先看模型,再写 SQL

⑤ wren context show # 确认模型名/列名(ch.02)
wren memory recall --nl "地区 营收"# 找相似历史查询(ch.04,可选)
wren query --sql "SELECT region, SUM(amount) …" # 走 MDL 语义层执行(ch.03)

⑥ agent 用自然语言把结果讲给用户

注意每一步都落在不同章:装配层(本章)负责让这些命令存在且默认值兜底;skills/ask(本章) 负责把知识和 prompt 喂给 agent;而 context / memory / query内部怎么实现,分别是 ch.02 / ch.04 / ch.03 的事。本章只到"agent 怎么进入它们"为止。


7. 巧妙之处(可带走的设计)

  • 内容随版本走,不进 agent 缓存。 skills 与 ask 模板都是 wheel 内 package data,用 importlib.resources 现取(skills_delivery.py:35 _content_rootask.py:30 render)。 升级 CLI = 升级指南,天然消除版本漂移。这是整章的中心思想。
  • 两层 skill 结构:稳定入口 + 易变内容分离。 50 行的 discovery stub(skills/wren/SKILL.md)只负责 "被发现 + 指路进 CLI",几乎不改;真正会随版本变的六份指南藏在 CLI 里(skills_content/)。 该进 agent 的最小化,该随版本的全下沉。
  • 指南只指路、不抄录。 onboarding/SKILL.md 把 per-datasource 细节和 troubleshooting 明确甩给 docs, 自己只强制 agent 侧规则 + 派发。SKILL.md 因此保持精悍、不重复。
  • ask 是纯 prompt 工厂,零副作用。 render 只做模板替换、绝不执行(ask.py:26)——职责单一、易测。
  • 拒绝会改变 agent 行为的隐式默认。 wren ask 不设默认模式,逼你显式选 --guided/--direct (ask_cli.py:34-40 + ask_cli.py:2-6 注释),避免升级时静默改变行为。
  • 装配层替 agent 兜底默认值。 _require_mdl(cli.py:23)自动找 target/mdl.json_DEFAULT_CONN(cli.py:16-17)默认 ~/.wren/connection_info.json——每少一个必填参数,agent 就少一处出错。

8. 边界与局限(本章范围内)

  • skills 内容是 wheel 内的静态 markdown,不是动态生成:改指南要发新版包。好处是可复现,代价是"热更新"要走发布。
  • get_script 只按文件名主干匹配(skills_delivery.py:72-79),同名不同扩展名会撞;bundled 脚本目前很少(如 introspect_dlt.py),尚不成问题。
  • ask 完全不校验用户 prompt 内容——它只做字符串替换(ask.py:26)。安全/防注入由消费 prompt 的上游 agent 负责,不是 ask 的职责。
  • wren ask 只产出 prompt,不闭环执行:真正的多步编排、工具调用、结果解读都在 agent 侧。Wren 提供"料"和"入口",不提供"大脑"。
  • 本章不覆盖:MDL 如何 build 与语义(ch.02)、SQL 如何被治理改写(ch.03)、记忆如何索引召回(ch.04)、GenBI 如何生成部署(ch.05)。这些命令如何挂上在本章,如何工作在各自章。

9. 代码地图(导航索引)

主题文件路径符号 / 锚点
根 typer appcore/wren/src/wren/cli.py:14app = typer.Typer(name="wren")
子命令统一装配core/wren/src/wren/cli.py:594-623add_typer(...) × 多个 / command(name="ask")
默认命令=querycore/wren/src/wren/cli.py:355main(@app.callback(invoke_without_command=True))
显式 query 子命令core/wren/src/wren/cli.py:428query
MDL 默认发现core/wren/src/wren/cli.py:23_require_mdl
连接信息默认路径core/wren/src/wren/cli.py:16-17_WREN_HOME / _DEFAULT_CONN
skills 下发核心core/wren/src/wren/skills_delivery.py:48get_skill
内容根(wheel 内)core/wren/src/wren/skills_delivery.py:35_content_root(_CONTENT_DIR="skills_content")
skill 列表数据结构core/wren/src/wren/skills_delivery.py:27SkillInfo
列全部 / 取脚本core/wren/src/wren/skills_delivery.py:82,72list_skills / get_script
skills CLI 包装core/wren/src/wren/skills_cli.py:17,36list_cmd / get
bundled 指南内容core/wren/src/wren/skills_content/<name>/SKILL.mdonboarding / usage / generate-mdl / dlt-connector / enrich-context / genbi
ask 塑形核心core/wren/src/wren/ask.py:26render(MODES ask.py:19, UnknownAskModeError ask.py:22)
ask 模板core/wren/src/wren/ask_templates/{guided,direct}.md.tmpl<USER_PROMPT> 占位符
ask CLI(强制二选一)core/wren/src/wren/ask_cli.py:15,34-40ask
外部发现桩skills/wren/SKILL.md / skills/index.jsondiscovery stub(约 50 / 23 行)
docs 子命令core/wren/src/wren/docs_cli.py:12docs_connection_info
context / genbi / memory 挂载core/wren/src/wren/{context_cli,genbi/cli,memory/cli}.pycontext_app / genbi_app / memory_app(实现见 ch.02/05/04)

相邻章节: index · 本章 01 · 02 上下文层:MDL · 03 语义引擎治理 · 04 记忆与检索 · 05 GenBI 部署