Skip to content

Commit 56731be

Browse files
committed
feat(wire): 机器可读输出协议 —— 信封、效应、--format 归一
按 .agents/docs/2026-08-08-machine-readable-output-protocol-design.md 实施 W2–W4(W0 CDB 引号、W1 stdout 归属已在前两个提交)。 **协议本体单独成模块**(`src/wire.cppm`),不认识任何命令 —— 命令→效应表放在 `cli.cppm` 里、紧邻命令注册处,否则新增一条命令要改两个文件且容易漏。 三条设计,每条都来自实测而非偏好: 1. **信封自识别,客户端靠解析识别,不靠退出码。** `--protocol-version` 解决不了它 看起来能解决的问题:在它出现之前的每个 mcpp 上,它自己就是未知选项,而未知选项 过去把人类文本打到 stdout、rc=1、stderr 为空 —— 成功与失败同通道。换 `--json` 拼写也一样。所以判据只能是「stdout 能解析出 schemaVersion + kind」。 2. **效应集合,不是 destructive 布尔。** 实测:全新 MCPP_HOME 上 `xpkg parse` 与 `cache list` 什么都不建,`self env` 建 6 项。布尔分不开「mcpp 给自己做初始化」和 「执行工作区里的代码」,而 IDE 的门只在乎后者。效应同时进 `--protocol-version` 的静态表 —— 门必须在运行前决定,等信封到手事情已经发生了。 3. **`--json` 永久保留 legacy payload,`--format json` 才带信封。** 拼写兼容不等于 payload 兼容:`cache list --json` 顶层是 `{root, entries}` 且本仓库 e2e 已断言。 两种拼写由同一个来源产出,不会漂移。 `self env --format json` 走独立的只读路径,不调 `load_or_init`:客户端问「东西在哪」 不该成为把东西放到那儿的原因。全新 home 上返回完整路径 + `initialized: false`, 创建项数 **0**(人类路径不变,仍会初始化)。 未知选项与不支持的值统一 stderr + rc=2,stdout 一字不写。 文档:`docs/11-machine-output.md` 与中文版,含「你可以依赖什么」一节 —— 并写明每个 kind 都有一个「改字段名就变红」的测试,因为没人能弄坏的 schema 不是 schema。 tests/unit/test_wire.cpp +14;68 单测;e2e 202
1 parent 5db9096 commit 56731be

12 files changed

Lines changed: 1080 additions & 38 deletions

‎docs/11-machine-output.md‎

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
# 11 — Machine-Readable Output
2+
3+
mcpp writes for two audiences. This chapter is the contract for the second one:
4+
programs. If you are building an editor extension, a CI script, or anything
5+
that parses mcpp's output, this is what you may rely on.
6+
7+
Design and the measurements behind it:
8+
`.agents/docs/2026-08-08-machine-readable-output-protocol-design.md`.
9+
10+
## 1. The rule that matters most
11+
12+
> **Detect the protocol by parsing stdout. Never by exit code, and never by
13+
> "the command did not fail".**
14+
15+
Read stdout, try to parse it as JSON, and require `schemaVersion` and `kind`
16+
to be present. If either is missing, this mcpp does not speak the protocol you
17+
asked for.
18+
19+
This is not a stylistic preference. `mcpp --protocol-version` looks like it
20+
should be the entry point, and on a version that has it, it is a useful
21+
shortcut. But on **every mcpp released before it existed**, that command is
22+
itself an unknown option — and an unknown option used to print human text to
23+
*stdout* with exit code 1 and an empty stderr. Success and failure arrived on
24+
the same channel. Spelling it `--json` instead changes nothing; both hit the
25+
same path.
26+
27+
So positive detection is the only rule that works across versions. Everything
28+
below is designed around it.
29+
30+
## 2. The envelope
31+
32+
Every enveloped response has this shape:
33+
34+
```jsonc
35+
{
36+
"schemaVersion": 1, // the ENVELOPE's version
37+
"kind": "mcpp.env", // which document this is
38+
"kindVersion": 1, // this kind's own data version
39+
"effects": [], // what running the command did — see §4
40+
"mcpp": {
41+
"version": "2026.8.8.3",
42+
"protocol": { "min": 1, "max": 1 }
43+
},
44+
"data": { /* specific to `kind` */ },
45+
"diagnostics": []
46+
}
47+
```
48+
49+
`schemaVersion` and `kindVersion` are separate on purpose. One global number
50+
would mean that adding a field to `mcpp.env` moves the version a client reads
51+
for `mcpp.xpkg`, with no way to tell which actually changed.
52+
53+
`effects` is always present. An empty array means "nothing"; an absent array
54+
would mean "unknown", which is a different claim.
55+
56+
### Diagnostics
57+
58+
```jsonc
59+
{
60+
"code": "MCPP_MANIFEST_UNKNOWN_KEY",
61+
"severity": "error" | "warning" | "note",
62+
"source": "mcpp",
63+
"message": "unknown key 'standrad'",
64+
"path": "mcpp.toml", // omitted when there is none
65+
"range": { "start": {"line": 3, "column": 1},
66+
"end": {"line": 3, "column": 9} } // omitted when there is none
67+
}
68+
```
69+
70+
Positions are 1-based. `column` counts UTF-8 **bytes**, so it indexes the same
71+
file mcpp read.
72+
73+
A diagnostic without a location omits `path` and `range` rather than sending
74+
zeros — `line: 0` would point at a position that does not exist.
75+
76+
`code` is always present. Parse `code`; never parse `message`.
77+
78+
## 3. Asking for machine output
79+
80+
```
81+
mcpp <command> --format json
82+
```
83+
84+
`json` is the only supported value today. `ndjson` is reserved for a future
85+
streaming case and is **not** accepted — asking for it is an error, not a
86+
silent fallback.
87+
88+
### Unsupported values and unknown options
89+
90+
Both go to **stderr** with **exit code 2**, and write nothing to stdout:
91+
92+
```
93+
$ mcpp self env --format yaml
94+
error: unsupported --format 'yaml'; expected: json # stderr
95+
$ echo $?
96+
2
97+
```
98+
99+
A request that does not yet know what it will be given must not write into the
100+
channel the protocol owns. Combined with §1, a client's rule is complete: no
101+
JSON on stdout means "not supported", whatever the reason.
102+
103+
Exit codes:
104+
105+
| code | meaning |
106+
|---|---|
107+
| 0 | success |
108+
| 2 | usage error — unknown option, unsupported value |
109+
| 70 | internal error (uncaught exception) |
110+
| 127 | unknown command |
111+
112+
## 4. Effects — what a command does before it prints
113+
114+
An IDE with an untrusted-workspace gate has to decide **before** running.
115+
By the time an envelope arrives, whatever it describes has already happened.
116+
So the same information is available statically:
117+
118+
```
119+
mcpp --protocol-version
120+
```
121+
122+
```jsonc
123+
{
124+
"schemaVersion": 1,
125+
"kind": "mcpp.protocol",
126+
"envelope": { "min": 1, "max": 1 },
127+
"kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 },
128+
"commands": {
129+
"self env": { "effects": ["init-mcpp-home"] },
130+
"xpkg parse": { "effects": [] },
131+
"cache list": { "effects": [] }
132+
}
133+
}
134+
```
135+
136+
Effects are named rather than a `destructive: true|false`, because a boolean
137+
cannot separate the harmless from the thing a gate exists for:
138+
139+
| effect | meaning |
140+
|---|---|
141+
| `init-mcpp-home` | may create `$MCPP_HOME` on first use. **Outside your project.** |
142+
| `read-project` | reads the manifest and sources |
143+
| `write-project` | writes into the project tree (`target/`, the compile DB) |
144+
| `write-global-cache` | writes the shared build cache |
145+
| `network` | may fetch |
146+
| `exec-build-script` | **runs code from the workspace** (`build.mcpp`) |
147+
148+
Most gates care about `exec-build-script` and `write-project`, and can ignore
149+
`init-mcpp-home` — mcpp setting itself up is not the workspace acting.
150+
151+
## 5. `--json` is not `--format json`
152+
153+
Two commands shipped a `--json` flag before this protocol existed:
154+
155+
```
156+
mcpp xpkg parse <file> --json -> {"namespace": …, "name": …, …}
157+
mcpp cache list --json -> {"root": …, "entries": [ … ]}
158+
```
159+
160+
Those payloads are **bare** — no envelope — and consumers already read them.
161+
So:
162+
163+
> **`--json` keeps its payload for ever. `--format json` is the enveloped one.**
164+
165+
`--json` is not deprecated, and using it prints no warning: clients parse this
166+
output, and a warning would land in the middle of it.
167+
168+
Both spellings are produced from the same source, so they always describe the
169+
same thing — one answer, two shapes.
170+
171+
## 6. What you may rely on, and what changes
172+
173+
For each `kind`, within a `kindVersion`:
174+
175+
- fields are **added**, never removed
176+
- the meaning of a field never changes
177+
- a breaking change bumps the version and, where a window is needed,
178+
`protocol.min`/`max` overlap so both are readable
179+
180+
That promise is only worth something if it is enforced, so each kind has a
181+
test that fails when a field name changes. A schema nobody can break is not a
182+
schema — `xlings interface --list` declares 20 capabilities whose
183+
`outputSchema` is, for all 20, only `{"exitCode": integer}`, and a client that
184+
sees a version number assumes there is a contract behind it.
185+
186+
## 7. Kinds
187+
188+
### `mcpp.env` — where mcpp keeps things
189+
190+
```
191+
mcpp self env --format json
192+
```
193+
194+
```jsonc
195+
{
196+
"initialized": false, // is there a config.toml yet?
197+
"mcppHome": "/home/u/.mcpp",
198+
"registry": "/home/u/.mcpp/registry",
199+
"xlingsHome": "/home/u/.mcpp/registry",
200+
"xlingsBinary":"/home/u/.mcpp/registry/bin/xlings",
201+
"config": "/home/u/.mcpp/config.toml",
202+
"buildCache": "/home/u/.mcpp/build-cache/v1",
203+
"mcppVersion": "2026.8.8.3"
204+
}
205+
```
206+
207+
This path is read-only, deliberately. The human `mcpp self env` initialises
208+
`$MCPP_HOME` if it is missing — someone typing it at a prompt expects that —
209+
but a client asking *where things are* should not be what puts them there. On
210+
a machine that has never run mcpp you get the paths it **would** use and
211+
`initialized: false`, and the disk is untouched.
212+
213+
That is why this exists at all: without it a client has to reimplement mcpp's
214+
home resolution, including the part where the `mcpp` on `PATH` may be an
215+
xlings shim rather than the real binary.
216+
217+
### `mcpp.xpkg` — a parsed descriptor
218+
219+
```
220+
mcpp xpkg parse <file.lua> --format json
221+
```
222+
223+
`data` is the same document `--json` prints bare.
224+
225+
### `mcpp.cache` — the global build cache
226+
227+
```
228+
mcpp cache list --format json
229+
```
230+
231+
`data` is `{root, entries[]}`, the same document `--json` prints bare.

‎docs/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- [08 - Toolchain Internals](08-toolchain-internals.md)
1414
- [09 - Releasing mcpp](09-release.md)
1515
- [10 - Publishing a Library to mcpp-index](10-publishing-a-library.md)
16+
- [11 - Machine-Readable Output](11-machine-output.md)
1617

1718
## Specifications
1819

0 commit comments

Comments
 (0)