# AI CLI 与官方 skill

> 用 ducklocal CLI 无界面执行 DuckDB SQL：查看本地文件 schema、输出有边界的 JSON、校验仪表盘，并配合官方 AI agent skill。

Source: https://ducklocal.app/docs/zh/cli

**[English](../cli.md)**

## 无窗口运行

`ducklocal --help` 列出九个子命令：

| 命令 | 作用 |
| --- | --- |
| `ducklocal query` | 执行一条 SQL，把结果输出为 JSON |
| `ducklocal schema` | 列出有哪些东西可查、各有哪些列；`--stats` 给出单个关系的逐列统计 |
| `ducklocal open` | 把 SQL、仪表盘或数据显示到运行中的窗口里；`--state` 读回窗口当前的内容 |
| `ducklocal check` | 校验 `.dash` 仪表盘 |
| `ducklocal comments` | 读取并回复别人在仪表盘上留下的审阅评论 |
| `ducklocal propose` | 提议一处仪表盘修改，由人审阅后应用 |
| `ducklocal project` | 检查可移动的分析文件夹及其依赖；`relink` 修正移动后失效的路径 |
| `ducklocal session` | 读回一次分析是怎么一步步得出结论的 |
| `ducklocal presence` | 显示谁正在处理已打开的仪表盘，以及接下来要改哪些图表 |

`ducklocal lsp`（编辑器为 `.dash` 文件启动的 language server）和 `ducklocal profile`（`schema --stats` 的旧写法）仍然可用，只是不列在 `--help` 里。

`propose`、`project`、`session` 三个子命令，以及 `--restricted`、`--wait`、`--if-revision` 选项，都是 0.4.0 新增的，详见 [Agent 工作流](agent-workflows.md)。

`ducklocal query` 执行 SQL 时，不会初始化 GUI、历史记录、已注册文件、语言设置，也不会创建 GUI 的全局连接。每次调用都会新建连接。若不带子命令（或仅传入文件、目录、glob 参数），则正常启动 GUI；若要在 GUI 中打开与子命令同名的路径（`query`、`schema`、`open`、`check`、`comments`、`propose`、`project`、`session`、`presence`、`profile`、`lsp`），请在前面加上 `./`，例如 `./query`。

## 获取命令 {#get-the-command}

DuckLocal 支持 macOS 12 及以上（Apple 芯片）、Windows（x86_64）和 Linux（x86_64）。CLI 就是应用本身的可执行文件，不需要另装 DuckDB CLI。

把命令加入 PATH，最快的办法是在应用里操作（0.1.1 及以上）：打开 **命令行与 AI…**（标题栏的机器人按钮，或 DuckLocal 菜单），点击 **安装命令**。同一个对话框还能为 agent（AI 助手）安装 skill，见[安装官方 skill](#安装官方-skill)。不安装的话，也可以按所在路径直接调用可执行文件：

```bash
# macOS
/Applications/DuckLocal.app/Contents/MacOS/ducklocal --help
# Linux：tar 包解压到的位置
./ducklocal-linux-x86_64/ducklocal --help
# Windows（PowerShell）：zip 解压到的位置
.\ducklocal-windows-x86_64\ducklocal.exe --help
```

从源码构建：在 DuckLocal 源码仓库运行 `cargo build --locked`，得到 `./target/debug/ducklocal`。以下示例假定 `ducklocal` 已在 PATH 中。旧版可能没有 `query`，先查看 `--help`。JSON 和 Parquet 支持已编入二进制，查询时不会再下载。

## 查询选项

```bash
ducklocal --help
ducklocal --version
ducklocal query --help
ducklocal query --sql "SELECT 1 AS n"
ducklocal query --sql "SELECT 1 AS n" --format md
ducklocal query --sql-file analysis.sql --limit 100
ducklocal query --sql-file - < analysis.sql
ducklocal query --database warehouse.duckdb --sql "SHOW TABLES"
```

- 必须且只能提供 `--sql SQL` 或 `--sql-file FILE`。文件必须为 UTF-8；`-` 从 stdin 读取。SQL 只能包含一条语句，DuckDB 解析器会在打开目标数据库、执行任何操作之前检查这一点；注释、引号里的分号、末尾的分号都没问题。多语句脚本直接拒绝，不会只执行其中一部分。
- `--database PATH` 默认以**只读**打开**已有文件**；省略则用新的内存数据库。`--read-write` 必须和 `--database` 一起用，明确允许写入和新建数据库（但不会创建父目录）。数据库出错时不会退回内存数据库。DuckDB 打不开某个文件时，错误信息开头是 DuckLocal 从文件头读出的原因（由更新的 DuckDB 写入、v0.9 时代的文件、SQLite 文件，或者根本不是数据库）以及该怎么办（更新版本写的文件，给出在那个版本里执行的 `ATTACH … (STORAGE_VERSION)` / `COPY FROM DATABASE` 复制语句；SQLite 文件，给出读取它的 `ATTACH`），后面才是 DuckDB 自己的报错。
- `--limit N` 为正整数，默认 1000，限制返回行数；另有 2,000,000 个单元格的预算。达到输出上限后，CLI 仍会继续统计最多额外 100,000 行，不再编码这些行的值，并报告 `total_rows` 与 `total_rows_exact`。这限制的是输出量，不是计算量或内存；排序、聚合仍可能处理全部输入。输出上限之后遇到的执行错误仍会使命令失败。
- `--format json`（默认）输出下文的 JSON 契约；`--format md` 把同一份结果输出为 Markdown 表格，方便放进文档或交给 agent 阅读。其他一概不变：同样的 SQL、同样的限制、同样的错误。
- 重复/未知参数、缺失值、空 SQL、非法 limit 均报错。参数和值要分开写，不支持 `--flag=value`。相对路径以进程的工作目录为准，而不是 SQL 文件所在的目录。shell 里的路径要加引号；SQL 里的路径含单引号时写两个（`'O''Brien.csv'`）；SQL 标识符用双引号。

**数据库只读，不等于文件系统或网络沙箱。** 即使数据库只读，`COPY` 这类 SQL 照样能写文件。输出路径、覆盖文件、修改数据库、安装扩展、访问外部资源，都要在执行前确认已获授权。DuckLocal 不会自动安装扩展，也不会自动重试；获得授权后仍可显式执行 `INSTALL`/`LOAD`，已安装的扩展可能自动加载。命令失败时也可能已经产生副作用（例如输出出错之前已经写入），重试前先检查目标位置。

### Markdown 输出

`--format md` 只是换一种呈现方式，不是另一份契约：值与 JSON 里的完全相同，写法与结果表格里的显示一致。

| 值 | 输出为 |
| --- | --- |
| `NULL` | `NULL` |
| 整数、DECIMAL、浮点 | 值本身的全部数字，一位不差：`DECIMAL(38,10)` 保留刻度，`NaN`/`inf`/`-inf` 原样写出 |
| `DATE` | `YYYY-MM-DD` |
| `TIMESTAMP` | `YYYY-MM-DD HH:MM:SS[.fffffffff]`，只在确有小数时带小数，有几位显示几位。不附加时区；原始计数在 `--format json` 里 |
| `TIME` | `HH:MM:SS[.ffffff]` |
| `INTERVAL` | 和 DuckDB 的写法一致：`1 month 2 days 00:00:00.000003` |
| `BLOB`、`GEOMETRY` | `0x00ff`，过长时以 `…` 省略 |
| `LIST`、`STRUCT`、`MAP` | `[1, 2]`、`{x: 1}`，超过 120 字符时以 `…` 省略 |

单元格里的 `|` 会转义，换行写成 `<br>`，值里有什么都不会打乱表格。结果为零行时，表格后面跟一行 `_0 rows._`；结果因 `--limit` 或单元格预算被截断时，后面跟 `_Truncated at 2 of 5000 rows, so this is not the whole answer._`（超过 100,000 行的计数上限时是 `of at least …`），免得把预览读成完整答案。出错时的行为不变：JSON 写到 stderr，stdout 为空。

## 看看有什么可查 {#map-what-can-be-queried}

`ducklocal schema` 一次回答"有哪些东西、各有哪些列"，`DESCRIBE` 则要一个关系一个关系地问。agent 应该先跑它。

```bash
ducklocal schema ./data/                          # 文件、文件夹、通配符
ducklocal schema --database warehouse.duckdb      # 表和视图
ducklocal schema warehouse.duckdb --table orders  # 单个关系的完整信息
ducklocal schema ./logs/ --full                   # 全部列出，不管多大
```

PATH 的展开方式和拖到窗口上一样：文件夹递归找出其中的数据文件（跳过隐藏文件，最多 256 个），通配符取所有匹配，数据库文件等同于用 `--database` 指定。返回一个 JSON 对象：

```json
{"detail":"full","relations":[{"name":"data/orders.csv","kind":"file","from":"'data/orders.csv'","rows":null,"column_count":2,"path":"data/orders.csv","columns":[{"name":"channel","type":"VARCHAR"},{"name":"amount","type":"BIGINT"}]}],"without_columns":0}
```

| 字段 | 含义 |
| --- | --- |
| `name` | 文件按给出的路径显示（在工作目录下时写成相对路径）；表按当前连接上 SQL 能访问到的名字显示：默认 schema 里只写表名，其他 schema 写 `schema.table` |
| `kind` | `file`、`glob`、`table`、`view` 或 `workbook` |
| `from` | 读取它的 SQL，可以直接接在 `FROM` 后面：文件是带引号的路径，需要时表名会加引号 |
| `rows` | 表取目录里的估计值，Parquet 取文件尾部记录的精确值；CSV、JSON、视图要扫描才知道，给 `null` |
| `column_count`、`columns` | DuckDB 描述的列 |
| `files` | `glob` 匹配到的文件数 |

匹配到的文件都是同一种格式时，一个 glob 算作一个关系，读法和 `.dash` 的 `source` 一样（`ducklocal schema 'logs/*.csv'`），`--stats` 也把它当作一个整体统计。格式混杂的 glob 和文件夹，则逐个列出其中的文件。

目录小时完整返回（`"detail": "full"`）。超过 12 个关系或 400 列时改为返回**摘要**（`"detail": "summary"`）。行数最多的 5 个关系带完整的列，其余只列名字、类型、行数和列数，`without_columns` 是这类关系的个数。要看更多，`hint` 给出两种办法：`--table NAME` 看其中一个（可以按列出的名字、不带 schema 的表名或文件名匹配），`--full` 看全部。工作簿没有对应的表函数，只列出 `sheets`，不给列；`--stats` 会读它的第一个工作表。PATH 不存在，或 `--table` 找不到对应的关系，退出码为 2。

## 探查与转换

确认文件实际存在后：

```bash
ducklocal query --sql "DESCRIBE SELECT * FROM 'sales.csv'"
ducklocal query --sql "SELECT * FROM 'sales.csv'" --limit 20
ducklocal query --sql "SELECT sum(amount) AS total FROM 'sales.csv'"
# 仅在确认此输出路径已获授权后运行：
ducklocal query --sql "COPY (SELECT * FROM 'sales.csv') TO 'sales.parquet' (FORMAT PARQUET)"
ducklocal query --sql "SELECT count(*), sum(amount) FROM 'sales.parquet'"
ducklocal query --sql "DESCRIBE SELECT * FROM 'events.json'"
```

别猜 `amount` 这样的列存不存在，先跑 `ducklocal schema` 或 DESCRIBE。完整的汇总要在 SQL 里算，不要把截断的预览加起来。CLI 调用看不到 GUI 注册的视图，彼此之间也不共享内存表；需要持久化时，显式写入数据库：

```bash
# 分两次调用执行，每次只有一条语句；写入必须获得授权。
ducklocal query --database warehouse.duckdb --read-write --sql "CREATE TABLE sales AS SELECT * FROM 'sales.csv'"
ducklocal query --database warehouse.duckdb --sql "SELECT count(*) FROM sales"
```

数据库锁冲突直接报错。关闭另一个写入方（包括 GUI），或改用经过授权的副本；不要删除锁文件，也不要悄悄另建一个数据库。

## JSONL 与嵌套 JSON

`read_ndjson_objects` 是换行分隔 JSON（每行一个对象，`.jsonl`/`.ndjson`）的读取函数；`read_json`/`read_json_auto` 读取的是 JSON 文档（一个数组或单个对象）。读 JSONL 文件时显式写明读取函数，别依赖自动探测。`json_extract_string(col, '$.a.b[0].c')` 按嵌套路径（对象键和数组下标）取值，返回 VARCHAR：

```bash
ducklocal query --sql "SELECT json_extract_string(event, '$.payload.items[0].sku') AS sku, count(*) AS n FROM read_ndjson_objects('events.jsonl') GROUP BY 1 ORDER BY n DESC"
```

叶子节点不是字符串、想按 JSON 取出时，改用 `json_extract`。

## 逐列统计

动手之前，得先定下画什么图、用什么刻度、数字怎么格式化，`ducklocal schema --stats` 回答的就是这些问题。`DESCRIBE` 只说某列是 `DOUBLE`；统计则告诉你：它实际只用两位小数，最大值是中位数的一千倍，日期在中间断了十一天。

```bash
ducklocal schema sales.csv --stats
ducklocal schema ./exports/ --table orders.parquet --stats
ducklocal schema --database warehouse.duckdb --table "my orders" --stats
```

`--stats` 只统计一个关系：参数只指向一个时就是它，否则用 `--table` 指定；指向两个以上又没写 `--table` 时退出码为 2，并提示加 `--table`。它不能和 `--full` 一起用。早先版本的 `ducklocal profile TARGET [--database PATH]` 输出同样的报告，现在仍然可用。

关系可以是数据文件（`csv`、`tsv`、`txt`、`parquet`、`json`、`ndjson`、`jsonl`），或工作簿（`xlsx`、`xls`、`xlsb`、`ods`，取第一个工作表导入为 TEMP 表后做逐列统计）；配合 `--database` 时，也可以是表名或视图名。带点的名字按 `schema.table` 拆开并逐段加引号，所以带空格或大写的名字可以照原样写。指向的对象不存在时，报参数错误（退出码 2），而不是 SQL 错误。

返回一个 JSON 对象：`target`、`relation`（统计实际针对的 SQL）、`row_count`、`elapsed_ms`，以及 `columns`（按关系本身的列顺序排列）。每列包含：

| 字段 | 含义 |
| --- | --- |
| `name`、`type` | 与 `DESCRIBE` 一致 |
| `nulls` | 该列为 `NULL` 的行数 |
| `distinct` | 精确值，不是估算 |
| `unique` | `distinct` 等于 `row_count` 时出现，值为 `true` |
| `min`、`max` | 以文本给出，`DECIMAL` 因此不丢位数 |
| `decimals` | 小数点后**实际用到**的位数，不是声明的刻度 |
| `median` | 该列真实存在的一个值，不是两个值之间的插值 |
| `max_over_median` | 最大值是中位数的多少倍；用线性刻度还是对数刻度，就看这个数 |
| `covered_days`、`span_days`、`missing_days` | 该列出现过的天数、首尾之间的天数，以及两者之差：`0` 表示连续，非零说明中间有空缺，折线会直接连过去 |

`decimals`、`median`、`max_over_median` 只出现在数值列，日期三项只出现在 DATE/TIMESTAMP 列。`LIST`、`STRUCT`、`MAP`、`UNION` 列只报告 `nulls`，因为这些类型没有定义 `min`/`max`。

如果文本列里是数字加一个短词（比如 R 的 `NA`），还会报告 `null_marker` 和 `null_marker_rows`，报告里也会多一条 `hint`，写明能把这一列读成数字的 `read_csv` 调用。

统计值是精确的，要读完整个关系，所以一次统计就是一次全表扫描。`--limit` 对它不起作用：只看样本，算不上统计。

## 把结果显示到运行中的窗口 {#show-work-in-the-running-window}

`ducklocal open` 把东西交给已经在运行的 DuckLocal 窗口（没有就先启动一个），并把窗口提到前面。agent 用它把结果摆到人面前，人可以接着改：

```bash
ducklocal open --title "Revenue by channel" --run --sql "SELECT channel, sum(amount) AS total FROM 'orders.csv' GROUP BY 1"
ducklocal open sales.dash                    # 打开为仪表盘标签页
ducklocal open ./data/ --sql-file analysis.sql
echo "SELECT 42" | ducklocal open --sql-file - --no-launch
```

- PATH 的打开方式和拖到窗口上一样：`.dash` 文件打开为标签页，DuckDB 数据库文件作为窗口的数据库打开，数据文件、文件夹和通配符挂载为视图。相对路径按命令所在的目录解析。窗口还在启动时收到的请求，会等启动时的打开完成后再处理。
- `--sql` 或 `--sql-file`（`-` 表示 stdin）打开一个新的查询标签页，标题为 `--title`；`--run` 会在 PATH 挂载完之后执行它。
- `--no-launch` 在没有窗口时直接失败（kind 为 `not_running`），不启动窗口。适合不希望突然弹出窗口的机器。

成功时输出 `{"delivered": true, "launched": BOOL}`：只表示送达，不是查询结果。打开的内容在窗口自己的会话里运行（它的连接、挂载的视图、历史记录），出错也显示在窗口里，不在 stdout。参数错误退出码为 2；无法送达时退出码为 1，kind 为 `not_running`（找不到窗口）、`refused`（窗口拒绝了请求）或 `launch`。

窗口在本机回环端口上监听，端口和一个随机会话令牌写在只有你自己能读的文件里（见[设置与应用数据](settings-and-data.md#where-things-are-stored)）；本机其他用户或指向 localhost 的网页都连不上。

### 读取窗口当前的内容

`ducklocal open --state` 方向相反：什么都不打开，也从不启动窗口，只输出运行中的窗口正在显示什么。这样 agent 可以从人停下的地方接着做，而不用请人把查询贴过来。

```bash
ducklocal open --state
ducklocal open --state sales.dash
ducklocal open --state --active
```

```json
{"database":":memory:","attached":[{"path":"/Users/me/data/orders.csv","name":"orders","rows":224}],"active_tab":1,"tabs":[{"kind":"query","title":"Query 1","active":false,"sql":"SELECT …","result":{"status":"rows","columns":[…],"row_count":5,"truncated":false,"preview":[["Web","132771.24"],…],"sql":"SELECT …","elapsed_ms":4}},{"kind":"dashboard","title":"sales","active":true,"path":"/Users/me/data/sales.dash","unsaved":false,"dashboard":{"running":false,"error":null,"stale":null,"plots":[…],"filters":[{"name":"channel","plot":"revenue_by_channel","column":"channel","picked":true,"value":"Web"}]}}]}
```

- `database` 是当前打开的数据库文件，或 `":memory:"`；`attached` 列出窗口注册为视图的文件及视图的 `name`。CLI 查询读不到这些视图，要用 `path`。
- **查询**标签页有 `sql`（编辑器里的文本，不管执行过没有）和 `result`：`status` 为 `empty`、`running`、`rows`、`affected`、`failed`、`explain` 或 `profile`。有行时给出 `columns`、`row_count`、`truncated`、`preview`（前 20 行，和表格里显示的一样，都是字符串）、`elapsed_ms` 以及产生它们的 `sql`；`affected` 给出 `count` 和 `elapsed_ms`；失败时给出 `error`。
- 用户在图表标签页选过列之后，结果里还会有 `chart`：`type`、`x`、`y`、`split`，以及 `dash`，即能在仪表盘里画出同一张图的 `query` 和 `plot` block。
- `explain` 对应**执行计划**按钮显示的内容：`plan` 是 DuckDB `EXPLAIN` 的输出，按行组成的字符串数组；另有 `elapsed_ms`。
- `profile` 对应**性能分析**按钮显示的内容（见 [SQL 编辑器](sql-editor.md)）：`plan` 是 `EXPLAIN ANALYZE` 实测的算子树，一个根节点数组，每个节点为 `{operator, seconds, rows, estimated, children}`。`seconds` 是该算子自身的耗时，不含子节点；`rows` 是它产出的行数；`estimated` 是优化器对 `rows` 的估计（计划里没给时为 `null`）；`children` 结构相同。`operator_seconds` 是所有算子自身耗时之和，各算子的占比以它为分母，它不是墙钟时间；`elapsed_ms` 是整次运行的耗时。
- **仪表盘**标签页有 `path`、`unsaved` 和 `dashboard`：其中包含 `comments_file` 路径、`plots`（名字、类型、标题、查询、行数、`open_comments`，画不出来时有 `failure`）和 `filters`（每个 filter 当前选中了什么）。`picked: true` 而 `value: null` 表示选中的是 NULL。

`--state sales.dash` 只保留显示该仪表盘的标签页，`--state --active` 只保留当前标签页。指定路径没有打开时，退出码为 1，kind 为 `not_open`，提示中列出已打开的仪表盘。`active_tab` 是当前标签页在返回的 `tabs` 中的位置；若它不在其中，则为 `null`。`database` 与 `attached` 始终保留。

`--state` 可带一个 `.dash` PATH 或 `--active`，两者不能同时使用；可以带 `--no-launch`，但不起作用；不能再带 `--sql`、`--run` 等其他选项（退出码 2）。没有窗口时退出码为 1，kind 为 `not_running`；窗口 5 秒内没有回应时 kind 为 `timeout`。

## 校验仪表盘规格文件 {#校验-dashboard-规格文件}

格式本身（block、属性、图表类型、SQL 规则与常见提示信息）见 [仪表盘（Dashboard，.dash）](dashboards.md)；本节讲命令本身。

`.dash` 文件用声明的方式描述仪表盘，就像 Terraform 声明基础设施：查询和图表写成 block，彼此通过引用连接，不用写脚本。人和 agent 都容易写、容易 diff。

```hcl
query "latency" {
  sql = <<SQL
    SELECT timestamp, service, avg(latency) AS latency
    FROM logs
    GROUP BY timestamp, service
  SQL
}

plot "latency" {
  type   = "line"
  query  = query.latency
  x      = timestamp
  y      = latency
  series = service
}
```

```bash
ducklocal check dashboard.dash
ducklocal check dashboard.dash --database warehouse.duckdb
ducklocal check dashboard.dash --summary
```

`source` block 给数据文件起名字，路径相对于 `.dash` 文件，供查询读取。`query` block 只有一个 `sql` 属性，内容是一条只读语句，写成 heredoc 或字符串均可。接受 `SELECT`、`WITH`、`FROM` 开头、`VALUES`、`SHOW`、`DESCRIBE`、`SUMMARIZE` 与 `PIVOT`；任何可能写入的语句（DDL、DML、`COPY`、`ATTACH`、`INSTALL`）都会报诊断，GUI 也拒绝执行，因为文件一打开，仪表盘的查询就会运行。`plot` block 含 `type`（`line`、`bar`、`stacked_bar`、`area`、`scatter`、`pie`、`heatmap`、`histogram`、`map`、`table`、`card`、`text` 之一）、`query`（指向同文件某个 query block 的引用，如 `query.latency`）、它用到的列，以及可选的 `title` 和 `width`。`filter` block 让柱状图或表格可以点选，查询用 `$name` 读取选中的值。`#` 和 `//` 之后到行尾都是注释。全部属性见 [仪表盘（Dashboard，.dash）](dashboards.md)。

`text` plot 是例外：它只显示 `title`、`text` 或两者，不接受 `query`。卡片还支持 `note` 与 `tone`，见[卡片与文本面板](dashboards.md#卡片与文本面板)。

先做静态检查：每条查询的 SQL 都在一次性连接上交给真正的 DuckDB parser 校验，与 `query` 执行前的校验相同；每个 `$name` 都必须对应一个 filter block，解析和执行 SQL 时，每个 `$name` 都按 `TRUE` 处理，也就是仪表盘什么都没选时执行的 SQL。

然后每条查询都真正执行一遍，和打开仪表盘时完全一样（只读，带着 source，同一条代码路径、同样的行数上限），并把每个 plot 用到的列与查询实际返回的列逐一核对：图表的 `y` 不是数值列算错误，地图坐标或 `size` 不是数值、filter 的 `column` 不在它那张图的查询结果里，也都算错误。

- **不带 `--database`** 时在内存里执行，只读自己 `source` 文件的仪表盘这样就够了。读取了某张 source 没定义的表的查询，可能是写给某个没提供的数据库的：它列在 `unresolved` 下（查询、行号、原因）而不算失败，它的 plot 也不核对列。
- **带 `--database PATH`**（已存在的文件，只读打开）时，在该数据库上执行。查询读的是数据库里的表时，一定要带上它：`unresolved` 下的都没核对过。

每个读取 filter 的查询，还会带一个选中值做绑定检查，但不执行这个变体。即使初始的 `$name = TRUE` 版本能运行，也能发现消费筛选条件的查询中缺少对应列的问题。筛选器的取值列也必须由点选图表的查询返回。

要真正执行而不只是生成查询计划，是因为有些错误（比如当前构建做不了的类型转换、数据里某个转换不了的值）要读到行时才会暴露；只做规划的 check 会放过它们，交出一个画不出来的仪表盘。查询会完整执行，所以对大库做 check，耗时和打开仪表盘差不多。

成功时输出一个 JSON 对象，列出 source（附带 `resolved` 路径）、查询（附带结果列和 `rows`：什么都不选时返回的行数；被仪表盘的行数预算截断时另有 `truncated: true`）、filter（附带读取它的 `queries`）、plot、`unresolved`，以及 `empty`：执行了但一行都没返回的查询。空查询能通过其他所有检查，但通常是正则、筛选条件或 join 什么都没匹配上，逐个看一眼。使用 `--summary` 时，成功输出改为一行精简 JSON：

```json
{"file":"sales.dash","ok":true,"sources":1,"queries":3,"filters":1,"plots":6,"unresolved":[],"empty":[]}
```

四种 block 字段为数量；`unresolved` 是未能核对的查询名列表，`empty` 是没有返回行的查询名列表。

有错的 block 会被跳过，其余有效 block 继续校验。同一次运行汇总规格诊断与查询失败，每条占一行 `文件:行号: 消息`。只要包含规格诊断，退出码就是 2，kind 为 `spec`；仅有 SQL 执行或绑定失败时，退出码为 1，kind 为 `sql`。数据库或 I/O 错误也以 1 退出。`--summary` 不会隐藏这些诊断。

同一个文件也能在 GUI 里作为仪表盘标签页打开（`ducklocal dashboard.dash`、`ducklocal open dashboard.dash`，或把文件拖到窗口上）：plot 排在 12 列的网格上；画不出来的 plot 在自己的位置上显示原因。见[仪表盘（.dash）](dashboards.md)。

## 回复仪表盘上的评论 {#review-comments-on-a-dashboard}

人在窗口里给 plot 写评论来审阅仪表盘：鼠标移到 plot 上，点对话气泡（见[用评论审阅](dashboards.md#review-with-comments)）。`ducklocal comments` 是 agent 这一侧：不开窗口，读取并回复这些评论。

```bash
ducklocal comments sales.dash [--all]
ducklocal comments sales.dash --reply c1 --text "已按收入降序排列" [--resolve]
ducklocal comments sales.dash --resolve c1
ducklocal comments sales.dash --reopen c1
ducklocal comments sales.dash --add revenue_by_channel --text "这里扣掉退款了吗？"
```

- 不带操作时，输出未解决的讨论：`{file, comments_file, open, threads}`。`--all` 连已解决的也列出。
- 每条讨论有 `id`、`plot`，以及在规格文件里找到它所需的信息：`plot_title`、`plot_query`，和 `plot_lines`（`plot` block 的首行与末行；规格文件解析不了时为 `null`）。同名的 plot 已经不存在时，`plot_missing` 为 `true`。然后是 `resolved`，以及 `comments`，每条有 `author`（`user` 或 `agent`）、agent 写的评论还有 `name`（就是 [`presence`](#agent-presence) 显示的名字）、`text` 和 `at`（RFC 3339，本地时间）。
- `--reply ID --text TEXT` 添加 agent 的回复。回复一条已解决的讨论会重新打开它，除非同时给了 `--resolve`。
- `--resolve ID` 和 `--reopen ID` 改变讨论的状态；`--add PLOT --text TEXT` 以 agent 的身份新开一条讨论。
- 讨论的编号也可以当 ID 用：`--reply 1` 就是 `c1`。`--add` 不接受规格文件里没有的 plot（退出码 2），提示里会列出已有的 plot。
- 有操作时输出修改后的 `{thread}`。

讨论保存在规格文件旁边的 `FILE.comments.json` 里。运行中的窗口一秒内就能察觉变化，并显示在对应的 plot 上。讨论 id 不存在时退出码为 2，并提示先列出讨论；规格文件不存在时退出码为 1。

一次完整的审阅：读出未解决的讨论，修改规格文件，跑 `ducklocal check`，再逐条回复改了什么并 `--resolve`。想反过来问对方时，只用 `--reply`，不加 `--resolve`。

## 显示 agent 的工作状态 {#agent-presence}

`check` 和 `comments` 会自动告知运行中的窗口，agent 正在处理哪份仪表盘。`presence` 还可以提前说明下一次修改的位置：

```bash
ducklocal presence sales.dash --doing editing --plot revenue_by_channel,daily_revenue
ducklocal presence sales.dash --doing reading --agent "Analyst"
ducklocal presence sales.dash --doing idle
```

- `--doing` 接受 `reading`、`editing` 或 `idle`，`done` 是 `idle` 的别名。
- `--plot` 接受逗号分隔的 plot 名。`editing` 允许尚不存在的名字，窗口会显示占位提示；`reading` 或 `idle` 指定未知名字时，退出码为 2。
- 显示名称依次取自 `--agent`、`DUCKLOCAL_AGENT`、识别到的 agent 环境（Claude、Codex、Cursor、Gemini），最后回退为 `Agent`。
- 输出为 `{delivered}`。没有运行中的窗口时返回 `{"delivered":false}`，不报错，也不启动窗口。

命令持续活动时，仪表盘角落会保留 agent 名字，点击可看活动记录。30 秒没有活动，或传入 `--doing idle` 后，会话结束；改动和新增的图表仍保留标记，直到被看过，删除提示则需要手动关闭。见[看见 agent 正在做什么](dashboards.md#agent-presence)。

## 用 LSP 编辑仪表盘规格文件 {#用-lsp-编辑-dashboard-规格文件}

`ducklocal lsp` 是面向 `.dash` 文件的 Language Server Protocol 服务器，通过 stdio 通信，由编辑器启动。任何支持 LSP 的编辑器都能借此获得 GUI 自带规格编辑器的功能：

- **诊断**：与 `check` 报告的一致，每次打开和修改时推送（全量文档同步，严重级别一律为 Error，因为它们是错误，不是风格建议）。
- **补全**：block 类型、属性名、图表类型以及 `query.`、`plot.` 后面的名字，每一项都带一个 text edit，替换正在输入的单词。
- **悬停**：block、属性和引用的文档。
- **跳转到定义**：从 `query.name` 或 `plot.name` 引用跳到对应 block 的名字。

带 `--database PATH` 时，每条查询的 SQL 还会交给真正的 DuckDB parser 校验，与 `check --database` 相同。

在编辑器的通用 LSP 支持里，为 `*.dash` 文件配置 `ducklocal lsp` 即可。命令不接受文档参数，编辑器通过 stdio 与它通信。Neovim（0.10+）示例：

```lua
vim.api.nvim_create_autocmd("FileType", {
  pattern = "dash",
  callback = function(event)
    vim.lsp.start({
      name = "ducklocal",
      cmd = { "ducklocal", "lsp" },
      root_dir = vim.fs.dirname(vim.api.nvim_buf_get_name(event.buf)),
    })
  end,
})
```

VS Code 里任何通用 LSP 扩展都可以用命令 `ducklocal`、参数 `["lsp"]`、文档选择器 `dash` 完成同样配置。退出码：正常 `shutdown`/`exit` 为 **0**；客户端没走完退出流程就断开为 **1**；命令行错误或协议握手失败为 **2**。

## JSON 契约

成功时 stdout 为单个 JSON 对象，末尾换行：

```json
{"columns":[{"name":"n","type":"Int32"}],"rows":[[1]],"row_count":1,"truncated":false,"total_rows":1,"total_rows_exact":true,"elapsed_ms":1}
```

`columns` 和每行都保留列顺序与重复列名。`type` 是 **Arrow debug 表示**，不是 DuckDB 原始 SQL 类型名（HUGEINT 与 DECIMAL 等可能使用相同 Arrow 表示）。`row_count` 为返回行数。`total_rows` 包含输出上限之后继续统计到的行；`total_rows_exact: false` 表示额外统计最多 100,000 行后停止，该总数只是下限。这两个字段都不代表修改行数。`elapsed_ms` 包含准备、执行、行编码，不含读取 SQL 文件及建立连接。零行结果也带列信息。DDL/DML 按 DuckDB 的实际结果输出，包括 `Count` 列：INSERT/COPY 通常返回 `[[N]]`，CREATE TABLE 通常返回零行；不根据 SQL 的第一个关键字来猜。

单元格编码：

| SQL 值 | JSON 编码 |
| --- | --- |
| NULL | `null`，不是字符串 `"NULL"` |
| 布尔、有限浮点数 | JSON 布尔/数字 |
| ±9,007,199,254,740,991 范围内整数 | JSON 数字 |
| 更大整数（含 HUGEINT/UHUGEINT） | `{"encoding":"integer","value":"9007199254740992"}` |
| Decimal | `{"encoding":"decimal","value":"123.450"}`，精确十进制文本 |
| 文本、enum | JSON 字符串 |
| Blob、geometry | `{"encoding":"hex","value":"00ff"}`，小写十六进制；geometry 为 WKB |
| Date | `{"encoding":"date","unit":"Day","value":"1","iso":"1970-01-02"}`，自 1970-01-01 起天数 |
| Timestamp | `{"encoding":"timestamp","unit":"Nanosecond","value":"123456789","iso":"1970-01-01T00:00:00.123456789"}`，自 Unix epoch 起有符号计数；也可能是 Second/Millisecond/Microsecond |
| Time | `{"encoding":"time","unit":"Microsecond","value":"123456","iso":"00:00:00.123456"}`，自午夜起计数 |
| Interval | `{"encoding":"interval","months":1,"days":2,"nanos":"3000"}` |
| NaN、正/负无穷 | `{"encoding":"float","value":"NaN"}` / `"inf"` / `"-inf"` |
| List、array | 递归编码的 JSON 数组，保留嵌套 null |
| Struct | `{"encoding":"struct","fields":[["name",VALUE],...]}` |
| Map | `{"encoding":"map","entries":[[KEY,VALUE],...]}`，键保留类型，不转换为 JSON 对象 |
| Union | `{"encoding":"union-value","value":VALUE}`，仅活动值，不保留成员标签 |

时间值的 `value` 是精确的原始计数，不会丢失小数精度；日期和时间戳的无穷哨兵值同样保留原始计数。旁边的 `iso` 是同一个值的 ISO 8601 文本，方便阅读，小数位数和单位的精度一致。带时区的列（Arrow 元数据）里的时间戳是 UTC 时刻，`iso` 以 `Z` 结尾。`--format md` 也以可读形式输出日期和时间戳。当前 DuckDB Arrow 封装分不清嵌套的 HUGEINT、UHUGEINT 和 DECIMAL(38,0)，遇到这类非空值会明确报错，不会悄悄改变符号或精度；这类字段要在 SQL 里显式 CAST 为 VARCHAR。其他不支持的未知类型也会报错，别把报错当成空结果。Union 只输出活动值，无法无损往返还原 union 标签。

错误时 stdout 留空，**stderr** 输出单个 JSON 对象：

```json
{"error":{"kind":"argument","message":"Unknown query option: --limt","hint":"Did you mean --limit?"}}
```

有明确的下一步时会带 `hint`，用的是这个 CLI 自己的说法：拼错的参数本来是哪个；只读数据库上写入被拒时加 `--read-write`；表不存在时先跑 `ducklocal schema`；值转换不了时用 `TRY_CAST`；一次传了多条语句时拆开；把关键字当裸别名时加 `AS`（`SELECT count(*) AS minutes`，或者给别名加引号）。agent 应先照它做，再考虑改写 SQL。DuckDB 自己的信息已经说清楚时（比如语法错误）不带 `hint`。

退出码：**0** 成功，**2** 参数/语句数量错误，**1** SQL、数据库、I/O 或输出错误。错误 kind 为 `argument`、`sql`、`database`、`io`、`output`，`check` 的 `spec`，以及 `open` 的 `not_running`、`not_open`、`refused`、`timeout`、`launch`。先看退出码再解析 stdout；说结果完整之前，先检查 `truncated`。帮助和版本信息是例外，输出纯文本。stdout 管道被关闭之类的传输错误，无法保证输出为空或完整。

## 安装官方 skill

skill 教会 agent 几个让答案可信的习惯：先看 schema，再做有限预览，用 SQL 算聚合，检查 `truncated`，并基于返回的值回答。它覆盖 schema 探查、逐列统计、格式转换、数据库查询、`.dash` 仪表盘及其审阅评论，以及把结果交给窗口并读回；不提供 S3 浏览、空间专用命令、会话记忆或 MCP 服务。

**在应用里安装。** 打开 **命令行与 AI…**（标题栏的机器人按钮，或 DuckLocal 菜单）：

- **安装命令**：把 `ducklocal` 加入 `PATH`，agent 的 shell 才能运行它。
- **添加到 Claude Code**：把 skill 写入 `~/.claude/skills/ducklocal`，Claude Code 在所有项目里都能找到。只写 skill 自己的两个文件，该目录下的其他内容保持不变。DuckLocal 更新带来新版 skill 时，对话框会提供 **更新技能**。
- **复制说明**：复制一段简短说明，粘贴给 Codex、Cursor 或任何聊天窗口，告诉它命令在哪、怎么用。

**手动安装。** skill 位于 [`skills/ducklocal`](https://github.com/JetSquirrel/ducklocal/tree/main/skills/ducklocal)。如果只想加到某一个项目（以 Claude Code 为例）：

```bash
# 在 DuckLocal 仓库根目录执行；把目标路径换成你的项目。
mkdir -p /path/to/your-project/.claude/skills
cp -R skills/ducklocal /path/to/your-project/.claude/skills/
```

目标已存在时先检查，避免覆盖本地修改。其他 agent 可以把同样的 `SKILL.md` 与 `references/cli.md` 放进各自支持的 skill 目录。
