treaty 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. treaty-0.0.1/LICENSE +21 -0
  2. treaty-0.0.1/PKG-INFO +423 -0
  3. treaty-0.0.1/README.md +403 -0
  4. treaty-0.0.1/pyproject.toml +74 -0
  5. treaty-0.0.1/pyproject.toml.orig +59 -0
  6. treaty-0.0.1/src/treaty/__init__.py +44 -0
  7. treaty-0.0.1/src/treaty/_app.py +1626 -0
  8. treaty-0.0.1/src/treaty/_audit.py +295 -0
  9. treaty-0.0.1/src/treaty/_cap.py +236 -0
  10. treaty-0.0.1/src/treaty/_cli.py +413 -0
  11. treaty-0.0.1/src/treaty/_command.py +252 -0
  12. treaty-0.0.1/src/treaty/_context.py +21 -0
  13. treaty-0.0.1/src/treaty/_dispatch.py +62 -0
  14. treaty-0.0.1/src/treaty/_effect.py +61 -0
  15. treaty-0.0.1/src/treaty/_envelope.py +140 -0
  16. treaty-0.0.1/src/treaty/_errors.py +135 -0
  17. treaty-0.0.1/src/treaty/_exit.py +205 -0
  18. treaty-0.0.1/src/treaty/_flags.py +474 -0
  19. treaty-0.0.1/src/treaty/_help.py +135 -0
  20. treaty-0.0.1/src/treaty/_idempotency.py +364 -0
  21. treaty-0.0.1/src/treaty/_manifest.py +264 -0
  22. treaty-0.0.1/src/treaty/_mcp.py +252 -0
  23. treaty-0.0.1/src/treaty/_mode.py +34 -0
  24. treaty-0.0.1/src/treaty/_parse.py +649 -0
  25. treaty-0.0.1/src/treaty/_paths.py +43 -0
  26. treaty-0.0.1/src/treaty/_profile.py +184 -0
  27. treaty-0.0.1/src/treaty/_resources.py +136 -0
  28. treaty-0.0.1/src/treaty/_scaffold.py +267 -0
  29. treaty-0.0.1/src/treaty/_scalars.py +175 -0
  30. treaty-0.0.1/src/treaty/_schema.py +174 -0
  31. treaty-0.0.1/src/treaty/_secrets.py +93 -0
  32. treaty-0.0.1/src/treaty/_signals.py +109 -0
  33. treaty-0.0.1/src/treaty/_timeout.py +141 -0
  34. treaty-0.0.1/src/treaty/_types.py +119 -0
  35. treaty-0.0.1/src/treaty/_values.py +115 -0
  36. treaty-0.0.1/src/treaty/py.typed +0 -0
treaty-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Romamo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
treaty-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,423 @@
1
+ Metadata-Version: 2.4
2
+ Name: treaty
3
+ Version: 0.0.1
4
+ Summary: Zero-dependency Python CLI framework implementing the CLI Agent Spec: manifests, envelopes, typed exit codes
5
+ Keywords: cli,agent,ai,manifest,json,exit-codes
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
12
+ Classifier: Topic :: Terminals
13
+ Requires-Dist: mcp>=2,<3 ; extra == 'mcp'
14
+ Requires-Python: >=3.14
15
+ Project-URL: Homepage, https://github.com/romamo/treaty
16
+ Project-URL: Repository, https://github.com/romamo/treaty
17
+ Project-URL: Issues, https://github.com/romamo/treaty/issues
18
+ Provides-Extra: mcp
19
+ Description-Content-Type: text/markdown
20
+
21
+ # treaty
22
+
23
+ Zero-dependency Python CLI framework that implements the
24
+ [CLI Agent Spec](https://github.com/cli-agent-spec/cli-agent-spec): a manifest an agent can read in one call,
25
+ a response envelope on every exit, and typed exit codes with retry semantics.
26
+
27
+ The manifest is the treaty between the CLI author and the agent. Exit code
28
+ entries are its clauses.
29
+
30
+ ```python
31
+ from dataclasses import dataclass
32
+ from treaty import App, Arg, Ctx, Exit, Flag
33
+
34
+ app = App("deployctl", version="1.4.0")
35
+ app.exit_code("DEPLOY_CONFLICT", 79, description="Target already has a deployment in progress",
36
+ retryable=False, side_effects="none")
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class Rollback:
40
+ service: str = Arg(description="Service name")
41
+ to: str | None = Flag(default=None, description="Release tag to roll back to")
42
+ dry_run: bool = Flag(default=False, description="Plan the rollback, write nothing")
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class Plan:
46
+ effect: str
47
+ service: str
48
+ release: str
49
+
50
+ deploy = app.group("deploy", description="Manage deployments")
51
+
52
+ @deploy.command("rollback", description="Roll a service back to its previous release",
53
+ danger_level="destructive", exit_codes=["DEPLOY_CONFLICT"])
54
+ def rollback(args: Rollback, ctx: Ctx) -> Plan:
55
+ if args.to is None:
56
+ raise Exit.DEPLOY_CONFLICT("No previous release recorded", context={"service": args.service})
57
+ effect = "would_update" if args.dry_run else "updated"
58
+ return Plan(effect=effect, service=args.service, release=args.to)
59
+
60
+ if __name__ == "__main__":
61
+ app.main()
62
+ ```
63
+
64
+ Design decisions: zero runtime dependencies in core, handlers are plain functions
65
+ over a frozen dataclass of arguments, and every command lives in one flat registry
66
+ keyed by dot-path.
67
+
68
+ A handler raises only the exit codes its manifest entry lists: the ones in `exit_codes=`,
69
+ plus `GENERAL_ERROR`, `ARG_ERROR`, and `TIMEOUT` everywhere and `CONFLICT` and
70
+ `PRECONDITION` on mutating commands. Anything else, including framework names such as
71
+ `Exit.NOT_FOUND`, must be declared, or the run exits `1` with `UNDECLARED_EXIT_CODE`.
72
+
73
+ ## Install
74
+
75
+ Every command here is non-interactive and safe to repeat:
76
+
77
+ ```bash
78
+ uv tool install treaty # the treaty CLI on PATH
79
+ uv add treaty # the library, inside a uv project
80
+ treaty --version # verify: prints a JSON envelope, exits 0
81
+ ```
82
+
83
+ To track unreleased changes, install from a checkout instead:
84
+
85
+ ```bash
86
+ uv tool install --reinstall /path/to/treaty
87
+ uv add --editable /path/to/treaty
88
+ ```
89
+
90
+ ## Built-ins
91
+
92
+ Every app gets `manifest`, `version`, and `exec` (disable with `App(..., enable_exec=False)`).
93
+ `<app> --version` at the root is an alias for `<app> version`; a command's own `--version`
94
+ flag is never shadowed.
95
+ `exec` reads one `DispatchRequest` per stdin line and dispatches in-process, writing one
96
+ envelope per line with `_cmd` and `_line` in `meta`. A stream with no lines exits `2` with a
97
+ single `EMPTY_STREAM` envelope, a piped plan over 64 KiB (`App(max_stdin_bytes=...)` or
98
+ `TREATY_MAX_STDIN_BYTES`) exits `2` with `STDIN_TOO_LARGE` before anything runs, and
99
+ `--input-file PATH` reads a plan of any size from a file (`-` is stdin, capped). A terminal on
100
+ stdin exits `2` with `STDIN_IS_TTY` instead of waiting for input:
101
+
102
+ ```bash
103
+ printf '%s\n' '{"_cmd":"deploy.rollback","service":"api","_opts":{"to":"1.3.9"}}' \
104
+ | deployctl exec --ignore-errors --dry-run
105
+ ```
106
+
107
+ ## Flag order
108
+
109
+ `--format`, `--help`, `--schema`, and `--max-output` are global: they are accepted anywhere
110
+ before `--`, so a command cannot declare a flag with those names or the short `-h`. The
111
+ manifest lists them once, in its root `flags` map (ManifestResponse 3.0). Every other flag,
112
+ including `--timeout`, `--confirm-destructive`, `--idempotency-key`, and `--raw-payload`,
113
+ belongs to a command and goes after the full command path:
114
+
115
+ ```bash
116
+ deployctl --format json deploy rollback api --to 1.3.9 --dry-run # ok
117
+ deployctl deploy rollback api --to 1.3.9 --dry-run --format json # ok
118
+ deployctl --dry-run deploy rollback api --to 1.3.9 # ARG_ERROR
119
+ ```
120
+
121
+ Any option repeated with a different value exits `2` naming the option; repeating the same
122
+ value is accepted, and array flags accumulate. A negative number such as `-5` is a value,
123
+ not a flag.
124
+
125
+ A command flag placed before the path fails with `ARG_ERROR`, names the command the remaining
126
+ words resolve to in `context.command`, and puts the corrected order in `suggestion`. Human
127
+ mode prints every error's suggestion as a final `hint:` line on stderr.
128
+
129
+ ## Timeouts
130
+
131
+ Every handler runs under a wall-clock limit: `App(default_timeout=60)` app-wide,
132
+ `@app.command(..., timeout=5)` per command, and `--timeout` on any command declaring
133
+ `has_network_io=True` and on every streaming command (`--timeout 0` disables it; at most
134
+ one year). A stream buffered in-process (`App.call`, MCP) always has a deadline: the
135
+ caller's `timeout`, else the app default; `0` is refused there. On expiry the
136
+ framework writes a `TIMEOUT` envelope, exits `10`, and records `meta.timeout_ms` on every
137
+ response. Handlers read `ctx.timeout` to pass the same deadline to their network calls. An
138
+ idempotency key stays locked until a timed-out or cancelled handler really finishes, so a
139
+ retry never runs beside it: it waits up to its own timeout, then replays the recorded
140
+ result or exits `10` with `IDEMPOTENCY_KEY_BUSY`. An unusable state directory or a damaged
141
+ record exits `4` (`STATE_DIR_UNWRITABLE`, `IDEMPOTENCY_RECORD_CORRUPT`).
142
+
143
+ A handler that raises anything else exits `1` with `HANDLER_CRASHED`, naming the exception;
144
+ the traceback goes to stderr with secret values redacted. A result or `Exit` payload the
145
+ framework cannot serialize exits `1` with `INVALID_OUTPUT` or `INVALID_EXIT`.
146
+
147
+ ## Output size
148
+
149
+ JSON output is capped at 1 MiB per envelope: `App(max_output_bytes=...)` app-wide,
150
+ `TREATY_MAX_OUTPUT_BYTES` in the environment, or the global `--max-output` flag, in
151
+ increasing precedence. Past the cap the framework follows whichever child holds most of the
152
+ bytes and cuts the list, object, or string where no child dominates to the longest prefix
153
+ that fits. `meta` gets `truncated`, `total_bytes`, and a `truncation_hint` giving the cap
154
+ that returns everything (plus `total_count` and `returned_count` when `data` is a list), and
155
+ each cut adds a `FIELD_TRUNCATED` warning naming the field. Human mode is not capped.
156
+
157
+ ## Secrets
158
+
159
+ A field declared `Flag(secret=True)`, or whose name contains `token`, `secret`, `password`,
160
+ `key`, `credential`, or `auth`, never takes its value on the command line (REQ-C-016). The
161
+ framework exposes `--<name>-from-env VAR` and `--<name>-from-file PATH` instead
162
+ (REQ-O-022), and reads `<APP>_<NAME>` when neither is given; the manifest lists that default
163
+ in `secret_env_vars`. The value is read in the validation phase, coerced and pattern-checked
164
+ like any field, and handed to the handler as the field. A direct `--<name> VALUE`, a missing
165
+ variable, an unreadable or empty file, or a file path with `..` all exit `2` before anything
166
+ runs, and no error ever echoes the value: it shows as `"value": "[REDACTED]"`. Booleans are
167
+ never secrets; pass `secret=False` to opt a name like `author` out. A secret cannot be
168
+ positional, an array, or carry a short flag.
169
+
170
+ ```bash
171
+ deployctl push --token-from-env DEPLOY_TOKEN # reads $DEPLOY_TOKEN
172
+ deployctl push --token-from-file /run/secrets/tok # reads the file, one trailing newline dropped
173
+ DEPLOYCTL_TOKEN=... deployctl push # the default variable
174
+ ```
175
+
176
+ ## Validation errors
177
+
178
+ Phase 1 keeps going past a bad value, an unknown flag, or a refused secret, so one run
179
+ reports every argument error (REQ-F-015). The envelope's `error.errors` lists each one with
180
+ its `field`, `message`, and `context`; with several, the headline `message` is
181
+ `Validation failed: N errors` and `context.fields` names them. A single error keeps its own
182
+ message and context and lists itself. Framework flags (`--timeout`, `--idempotency-key`,
183
+ a repeat with a different value) and a flag with no value at the end are collected the same
184
+ way; only invalid `--raw-payload` JSON stops parsing at once.
185
+
186
+ ## Paths
187
+
188
+ A field annotated `pathlib.Path` (or `Path | None`, `tuple[Path, ...]`) reaches the handler as a
189
+ `Path` and is listed in the manifest with `pattern_type: "filepath"`. Before any handler runs,
190
+ on argv, `exec`, and `--raw-payload` alike, the framework rejects the agent hallucination
191
+ patterns of REQ-F-045 with exit `2`: any `..` segment, a percent-encoded sequence such as
192
+ `%2e%2e` or `%2f`, and null bytes. The error carries `rejected_pattern` in `context` and a
193
+ `suggestion` with the decoded or absolute form, so `../out.json` is refused but
194
+ `/abs/out.json` passes unchanged. `pattern=` is not allowed on `Path` fields. The audit rule
195
+ `path-typed` warns about `str` fields whose name looks like a path.
196
+
197
+ ## Custom scalars
198
+
199
+ A domain class can annotate a field or an output attribute once the app knows how to parse
200
+ it. Register it before the commands that use it; the class itself never imports treaty:
201
+
202
+ ```python
203
+ app.scalar(ResourceId, parse=ResourceId.from_boundary, pattern=r"[a-z][a-z0-9-]{0,62}")
204
+ app.scalar(TcpPort, parse=TcpPort, base=int, minimum=1, maximum=65535)
205
+ app.scalar(RunId, parse=RunId, pattern_type="uuid")
206
+ ```
207
+
208
+ The value travels as its `base` (`str`, `int`, or `float`) on argv, in `exec` lines, and in
209
+ `--raw-payload`. The framework coerces the base type, checks `pattern`, `pattern_type`, or
210
+ the bounds, then calls `parse`; a `ValueError` or `TypeError` from it is one entry in
211
+ `error.errors` with the class name and the cause in `context`. The manifest lists the field
212
+ under its base type with `pattern` or `pattern_type`, and `--schema` carries the pattern,
213
+ `format`, and bounds on both `raw_payload_schema` and `output_schema`. Outputs serialize
214
+ back through `serialize`, which defaults to the class's `value` field (or `str` for a `str`
215
+ base). `pattern=` on a field of a registered type is a registration error, as is annotating
216
+ an unregistered class. `pattern_type` takes the REQ-C-020 presets `alphanumeric_id`,
217
+ `uuid`, `semver`, and `url`; `filepath` stays with `pathlib.Path`.
218
+
219
+ ## Resources
220
+
221
+ A handler can take more parameters after `ctx`. Each one is annotated with a class that has
222
+ an `acquire` classmethod, and the framework calls it after validation, once per run, before
223
+ the handler:
224
+
225
+ ```python
226
+ @dataclass(frozen=True, slots=True)
227
+ class Project:
228
+ directory: Path
229
+
230
+ @classmethod
231
+ def acquire(cls, args: ProjectArgs, ctx: Ctx) -> Self:
232
+ directory = args.project or Path(ctx.env["PWD"])
233
+ if not (directory / "servers").is_dir():
234
+ raise Exit.NO_PROJECT("not a project", context={"directory": str(directory)})
235
+ return cls(directory)
236
+
237
+ @dataclass(frozen=True, slots=True)
238
+ class Config:
239
+ @classmethod
240
+ def acquire(cls, args: ProjectArgs, ctx: Ctx, project: Project) -> Self: ...
241
+
242
+ @app.command("deploy", description="Deploy a component", exit_codes=["NO_PROJECT"])
243
+ def deploy(args: DeployArgs, ctx: Ctx, config: Config, project: Project) -> Receipt: ...
244
+ ```
245
+
246
+ `acquire` takes the same `(args, ctx)` as a handler plus, optionally, other resources by
247
+ annotation, so resources compose. Each class is acquired at most once per run and shared,
248
+ in dependency order, and acquisition runs under the command's timeout. A `CliExit` raised
249
+ inside `acquire` becomes that exit's envelope and a `ParseError` becomes `ARG_ERROR`, so a
250
+ missing project fails before any handler runs. A class without a classmethod `acquire`, a
251
+ missing `Ctx` annotation, or a dependency cycle is a registration error. Resources are not
252
+ part of the manifest: the flags they read, such as `--project`, live on the args dataclass,
253
+ typically a `kw_only=True` base class shared by every command. Resources must not change
254
+ process state such as the working directory, because `exec` runs many requests in one
255
+ process.
256
+
257
+ ## Streaming
258
+
259
+ A command declared `streaming=True` has a generator handler annotated `Iterator[T]`, and
260
+ every yield is one JSONL envelope line with `meta.seq` counting from 1. The stream ends
261
+ with a terminal envelope that has `data: null`, `meta.end: true`, and `meta.total`, so an
262
+ agent can tell a clean end from a killed process:
263
+
264
+ ```python
265
+ @app.command("dashboard.serve", description="Serve the dashboard", streaming=True,
266
+ cleanup=stop_server)
267
+ def serve(args: ServeArgs, ctx: Ctx) -> Iterator[ServeEvent]:
268
+ server = start(args.port)
269
+ yield Listening(url=server.url)
270
+ try:
271
+ while True:
272
+ yield server.next_event()
273
+ finally:
274
+ server.close()
275
+ ```
276
+
277
+ A `CliExit`, `ParseError`, timeout, or signal after some events writes the matching failure
278
+ envelope as the last line, with `meta.seq` at the last delivered event and `meta.partial`.
279
+ Streaming commands default to no timeout; an explicit `timeout=` or `--timeout` is a
280
+ deadline for the whole stream. Cancellation runs `cleanup=` and the handler's `finally`
281
+ blocks, then ends the stream with the normal `CANCELLED` envelope and exit `130` or `143`.
282
+ The manifest declares `streaming_default: true` and a `--no-stream` flag (REQ-O-004),
283
+ which returns one envelope with every event in `data` and `meta.total`; a failure under
284
+ `--no-stream` keeps the events seen so far in `data`. In `exec`, each event line carries
285
+ `_line` and `_cmd`. Streaming commands must be `safe`: the effect and idempotency
286
+ contracts describe one response. In human mode `human=` renders each event.
287
+
288
+ ## Destructive commands
289
+
290
+ A command with `danger_level="destructive"` must declare a boolean `dry_run` field. Without
291
+ `--confirm-destructive` the framework runs it in dry-run mode and exits `2` with error code
292
+ `CONFIRMATION_REQUIRED`, so the `data` payload shows what would be affected without applying it.
293
+
294
+ ## Effects and idempotency keys
295
+
296
+ Mutating and destructive commands return an object with an `effect` field: `created`,
297
+ `updated`, `deleted`, or `noop` on a live run, and a `would_*` value such as `would_delete`
298
+ on a dry run. Registration fails when the output type cannot carry the field, and a run
299
+ that reports the wrong kind of value exits `1` with `INVALID_EFFECT`.
300
+
301
+ The framework gives those commands `--idempotency-key` (also `idempotency_key` in `exec`
302
+ lines and `--raw-payload`). A successful live run is stored under the key; repeating the
303
+ call returns the stored `data` with `effect: "noop"` and `meta.idempotency_hit: true`
304
+ without running the handler, and reusing the key with different arguments exits `6` with
305
+ `IDEMPOTENCY_KEY_REUSED`. Failures and dry runs are never stored, a concurrent retry waits
306
+ for the first call, and records expire after 24 hours. Records live in
307
+ `App(state_dir=...)`, else `$TREATY_STATE_DIR/<app>`, else `$XDG_STATE_HOME/treaty/<app>`,
308
+ else `~/.local/state/treaty/<app>`. Handlers read the key as `ctx.idempotency_key` to pass
309
+ it on to an upstream API.
310
+
311
+ ## Cancellation
312
+
313
+ SIGINT and SIGTERM produce a `CANCELLED` envelope with exit `130` or `143`, run the
314
+ command's optional `cleanup=` hook first, and a second signal during cleanup exits at once
315
+ without a second write. A handler's own `except Exception` cannot swallow the signal, and a
316
+ retry waiting for an idempotency key is interrupted too. A signal that arrives after the
317
+ handler returned is held: the finished result is written with its own exit code, since
318
+ the work it reports did happen. An `exec` plan stops at the first signal, even with
319
+ `--ignore-errors`, and exits `130` or `143`. A reader that closes stdout early
320
+ (`tool logs | head`) ends the run with `141` (`OUTPUT_CLOSED`): the cleanup hook runs and
321
+ nothing more is written. All three codes appear in every command's `exit_codes` map.
322
+
323
+ ## Raw payloads
324
+
325
+ Declare `supports_raw_payload=True` and the command accepts `--raw-payload '{"name": "x"}'`
326
+ as an alternative to individual flags. The payload is checked against the same field types as
327
+ `exec` lines, and mixing it with individual flags exits `2`.
328
+
329
+ ## Schemas
330
+
331
+ `tool <cmd> --schema` prints the command's manifest entry plus `parameters` and a draft-07
332
+ `output_schema` derived from the handler's return annotation. Commands with
333
+ `supports_raw_payload=True` also get `raw_payload_schema`, the JSON Schema of their args
334
+ dataclass. `tool --schema` prints the whole manifest with each command's full exit-code
335
+ table (a valid ManifestResponse, so without `parameters` or `raw_payload_schema`);
336
+ `tool <group> --schema` prints one group's subtree. The output is JSON in every mode.
337
+
338
+ ## MCP
339
+
340
+ With the `mcp` extra installed, any treaty app serves its commands as MCP tools over
341
+ stdio, in-process, with nothing to write:
342
+
343
+ ```bash
344
+ uv add --editable "/path/to/treaty[mcp]"
345
+ treaty-mcp deployctl:app
346
+ ```
347
+
348
+ One tool per command except `exec`, named with dots as underscores (`deploy_rollback`).
349
+ The input schema is the args dataclass schema with field names as declared, secrets
350
+ replaced by `<name>_from_env` and `<name>_from_file`, and the framework keys the command
351
+ declares: `timeout`, `idempotency_key`, and `confirm_destructive`. The output schema is
352
+ the response envelope around the command's `output_schema`, and every result carries the
353
+ envelope as `structuredContent` and as JSON text; `isError` mirrors `ok`. Calls go through
354
+ `App.call`, the same path as an `exec` line, so an unconfirmed destructive tool call
355
+ returns `CONFIRMATION_REQUIRED` with its dry-run preview, idempotency keys, timeouts,
356
+ effect validation, and output caps all apply, and a streaming command returns its buffered
357
+ envelope. Tool annotations map `safe` to read-only and idempotent, `destructive` to
358
+ destructive, and `has_network_io` to open-world. `App.call(path, arguments)` is public
359
+ for other in-process adapters.
360
+
361
+ ## Conformance
362
+
363
+ `conformance/deployctl.json` is a profile for the spec's deterministic kit. With the spec checked
364
+ out as a sibling directory:
365
+
366
+ ```bash
367
+ uv run --project ../cli-agent-ergonomics ../cli-agent-ergonomics/conformance/run.py conformance/deployctl.json
368
+ ```
369
+
370
+ The example CLI passes all twelve checks across levels 1 to 3. The same run is a pytest test
371
+ that skips when the spec checkout is absent.
372
+
373
+ ## Start a project
374
+
375
+ Run it beside a `cli-agent-ergonomics` checkout, which is the spec for the kit:
376
+
377
+ ```bash
378
+ cd .. # the directory holding cli-agent-ergonomics/
379
+ uvx treaty init shop-tool
380
+ cd shop-tool && uv sync && uv run pytest
381
+ uv run treaty conformance shop_tool.cli:app --run
382
+ ```
383
+
384
+ The new project depends on `treaty` from PyPI; `--treaty-source /path/to/treaty` pins a local
385
+ checkout instead.
386
+
387
+ `init` scaffolds a package with one command per danger level, typed outputs, declared exit
388
+ codes, a test using `app.run()`, and a conformance profile. `conformance` derives probes
389
+ from each command's first example and danger level, writes the profile, and with `--run`
390
+ executes the spec kit, exiting with `CONFORMANCE_FAILED` when checks fail. The kit is found
391
+ via `--spec-dir`, then `TREATY_SPEC_DIR`, then `../cli-agent-ergonomics` relative to the
392
+ current directory; a named location without `conformance/run.py` exits `4` instead of
393
+ falling through. `--out`, `--spec-dir`, and `--directory` reject `..` segments,
394
+ percent-encodings, and null bytes like every `Path` flag; pass an absolute path to reach
395
+ outside the working directory. Streaming commands get no probes: the kit expects one
396
+ envelope per run.
397
+
398
+ ## Audit your CLI
399
+
400
+ ```bash
401
+ uv run treaty audit myapp.cli:app
402
+ ```
403
+
404
+ Ten ordered rules check the registry and print the next steps with a fix using your own
405
+ names: missing examples, danger levels that contradict command names, mutating commands
406
+ without their own exit codes, retryable codes on non-idempotent commands, untyped outputs,
407
+ undeclared network I/O, path-like fields not typed `Path`, wide mutating commands without
408
+ `--raw-payload`, missing cleanup hooks, and a missing conformance profile. `--all` lists
409
+ everything, `--strict` exits 79 (`AUDIT_FAILED`) on any warning so CI can gate on it, and
410
+ piping the output gives an envelope an agent can act on. Rules see declarations only; the
411
+ conformance kit covers runtime behaviour.
412
+
413
+ ## Development
414
+
415
+ ```bash
416
+ uv sync
417
+ uv run pytest
418
+ uv run mypy src
419
+ uv run ruff check src tests
420
+ ```
421
+
422
+ The manifest test validates against the spec schemas in the sibling
423
+ `cli-agent-ergonomics` checkout; set `TREATY_SPEC_DIR` to point elsewhere.