Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ Default canonical labels: `needs-triage`, `needs-info`, `ready-for-agent`, `read

Single-context: one `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.

### Writing style

Standards for help text, the README, and SKILL.md; inline code in help prose is always backticked (test-enforced). See `docs/agents/writing-style.md`.

## Development

Setup, from-source install, the design-doc map, and the release process live in `CONTRIBUTING.md`. The README is user-facing only.
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ no Python required.
```

- `docs/telemetry.md`, `docs/user-agent.md` — wire-visible contracts.
- `docs/agents/writing-style.md` — standards for help text, the README,
and SKILL.md. Backticked inline code in help prose is enforced by
`tests/test_help_style.py`; the hosted docs generate their CLI
reference from `help --json`, so these strings are customer-facing.

## Releasing

Expand Down
36 changes: 36 additions & 0 deletions docs/agents/writing-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Writing style

Standards for every string a user or an agent reads: help text and
docstrings (which become `ade help` and the hosted CLI reference), the
README, and SKILL.md.

## Prose standards

- Second person ("you"), active voice, direct language.
- No promotional language. Describe what a thing does.
- No idioms. Plain wording wins.
- Test every code example before it lands. Fence README and SKILL.md
code blocks with a language tag.
- Error and status messages state what happened and what to do next.

## Inline code is always backticked (enforced)

Wrap flags (`--api-key`), commands (`ade update`), paths
(`~/.ade/credentials.json`), environment variables (`ADE_ENV`), and
literal values (`priority`) in backticks wherever they appear in prose.

Why: the hosted docs generate the CLI reference from `help --json` and
render a bare `--flag` as an em dash plus the flag name; agents need
unambiguous literal boundaries; and terminal output prints backticks
literally, which is already this CLI's voice.

`tests/test_help_style.py` enforces the flag rule across the help
surface's prose fields. Pre-formatted blocks are exempt: topic bodies
and usage lines are terminal layouts and copy-pasteable commands, so
leave them bare.

## Relationship to the docs repo

The docs repo (landing-ai/docs) has stricter rules for hosted pages.
This file governs text that lives in this repo; the docs repo's
generator is the compatibility layer between the two.
64 changes: 32 additions & 32 deletions docs/reference/help.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions src/ade_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ def login(
environment: str | None = typer.Option(None, "--env", help=_ENV_HELP),
as_json: bool = JSON_FLAG,
) -> None:
"""Ensure the target environment is logged in; --api-key
"""Ensure the target environment is logged in; `--api-key`
authenticates with a key directly ('-' prompts with hidden input).
Targets --env, else $ADE_ENV, else production — nothing is stored
Targets `--env`, else $ADE_ENV, else production — nothing is stored
about the choice."""
home = ade_home()
resolved = resolve_target(home, environment, as_json=as_json)
Expand Down Expand Up @@ -586,7 +586,7 @@ def logout(
),
as_json: bool = JSON_FLAG,
) -> None:
"""Log out of one environment (the resolved target by default); --all
"""Log out of one environment (the resolved target by default); `--all`
clears every environment. Idempotent; OAuth refresh tokens are revoked
best-effort first."""
home = ade_home()
Expand Down
2 changes: 1 addition & 1 deletion src/ade_cli/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ def extract(
help=f"Environment to run against: {', '.join(ENVIRONMENTS)} "
"(default: $ADE_ENV, then production). The JOB_ITEM_ID form inherits the "
"parse item's environment instead — its server-side parse job only "
"exists there — and a conflicting --env is refused.",
"exists there — and a conflicting `--env` is refused.",
),
wait: float = typer.Option(600.0, "--wait", help="Poll budget in seconds."),
force: bool = typer.Option(
Expand Down
4 changes: 2 additions & 2 deletions src/ade_cli/find.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def find(
None,
metavar="[JOB_ITEM_ID] [QUERY]",
help="Job item id (or unambiguous prefix) to search, then an "
"optional case-insensitive substring QUERY. With --job, the one "
"optional case-insensitive substring QUERY. With `--job`, the one "
"allowed positional is the QUERY.",
),
job_tokens: list[str] = typer.Option(
Expand Down Expand Up @@ -61,7 +61,7 @@ def find(
as_json: bool = JSON_FLAG,
id_only: bool = ID_ONLY_FLAG,
) -> None:
"""Search parsed elements locally: `find JOB_ITEM_ID [QUERY]`, or --job
"""Search parsed elements locally: `find JOB_ITEM_ID [QUERY]`, or `--job`
(repeatable) for several items; no query lists every element.

Ids discovered here are what `view --element-id` deep-links and
Expand Down
30 changes: 15 additions & 15 deletions src/ade_cli/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@
("stored", "false when the result could not be persisted"),
("store_dir", "absolute path of the job item folder"),
("artifacts", "artifact filenames written there"),
("markdown", "the parse markdown — only with --include markdown"),
("elements", "the flat projection — only with --include elements"),
("markdown", "the parse markdown — only with `--include markdown`"),
("elements", "the flat projection — only with `--include elements`"),
],
},
"extract": {
Expand Down Expand Up @@ -222,7 +222,7 @@
("text", "the element's markdown slice"),
],
"note": "One record per match, in document order; [] when nothing "
"matched. --id-only prints the element ids alone.",
"matched. `--id-only` prints the element ids alone.",
},
"crop": {
"shape": "object",
Expand All @@ -234,21 +234,21 @@
("crops", "one record per PNG (element_id, type, "
"page, box, dpi, path, width, height)"),
],
"note": "One shape whatever matched: a single --element-id is "
"count 1 with one crops[] record; a filter (--type/--page/--all) "
"note": "One shape whatever matched: a single `--element-id` is "
"count 1 with one crops[] record; a filter (`--type`/`--page`/`--all`) "
"matching nothing is count 0 with crops [].",
},
"view": {
"shape": "object",
"keys": [
("status", "'viewed' ('cropped' with --crop, 'synced' with --sync-viewers)"),
("status", "'viewed' ('cropped' with `--crop`, 'synced' with `--sync-viewers`)"),
("job_item_id", "the item rendered"),
("kind", "parse | extract"),
("path", "the self-contained view.html (or the PNG, with --crop)"),
("path", "the self-contained view.html (or the PNG, with `--crop`)"),
("built", "true when this run rebuilt the artifact"),
("pages_embedded", "pages inlined; the rest load from sidecars"),
("note", "why the render weakened, when it did (else null)"),
("deep_link", "view.html#element=... when --element-id was given"),
("deep_link", "view.html#element=... when `--element-id` was given"),
("history_items", "items in the rebuilt sidebar read model"),
("sidebar_sync", "true when sibling viewers build in the background"),
],
Expand All @@ -265,7 +265,7 @@
("parse", "extract items: the referenced parse — {job_item_id, "
"run_id (the parse generation extracted against), missing}"),
("stale", "extract items: true when the referenced parse was "
"--force re-run after this extraction"),
"`--force` re-run after this extraction"),
("created_at / completed_at", "epoch seconds (null when unknown)"),
],
},
Expand Down Expand Up @@ -308,7 +308,7 @@
("cleared", "false when there was nothing stored to clear"),
("revoked", "refresh tokens revoked best-effort"),
("scope", "environment | all"),
("environment", "the environment cleared (null with --all)"),
("environment", "the environment cleared (null with `--all`)"),
],
},
"version": {
Expand Down Expand Up @@ -387,16 +387,16 @@
CONVENTIONS = [
(
"--json",
"Every command supports --json: one stable JSON object/array on "
"Every command supports `--json`: one stable JSON object/array on "
"stdout (errors and pending payloads follow the same rule). Agents "
"should always pass it. Each command's published shape is its "
"'result' block below — the full result is always on stdout, never "
"only in a file.",
),
(
"--id-only",
"parse, extract, and find also take --id-only: just the id(s), one "
"per line, for piping (JOB=$(ade parse -d f.pdf --id-only)). Errors "
"parse, extract, and find also take `--id-only`: just the id(s), one "
"per line, for piping (`JOB=$(ade parse -d f.pdf --id-only)`). Errors "
"and hints go to stderr so a captured id is never a sentence.",
),
(
Expand All @@ -409,7 +409,7 @@
"guarantees",
"parse and extract ensure a run exists rather than fire a request: "
"an already-done run is served from disk free with an explicit "
"notice (--force consents to a re-bill); a pending run is resumed, "
"notice (`--force` consents to a re-bill); a pending run is resumed, "
"never resubmitted; Ctrl-C stops the waiting, not the work.",
),
(
Expand Down Expand Up @@ -677,7 +677,7 @@ def help_command(
"""Print the whole-surface command reference in one call: every
command and flag, the output convention, each verb's result shape,
exit states, and the store layout. The agent bootstrap — run this
(with --json) before anything else. `help TOPIC` prints one
(with `--json`) before anything else. `help TOPIC` prints one
conceptual page instead (workflow, output, credentials, errors)."""
root = ctx.find_root().command
assert isinstance(root, TyperGroup)
Expand Down
4 changes: 2 additions & 2 deletions src/ade_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,13 @@ def _force_utf8_stdio() -> None:
app.command(
"login",
help="Alias of `ade auth login`: ensure the target environment is "
"logged in; --api-key authenticates with a key directly ('-' prompts "
"logged in; `--api-key` authenticates with a key directly ('-' prompts "
"with hidden input).",
)(login)
app.command(
"logout",
help="Alias of `ade auth logout`: log out of one environment (the "
"resolved target by default); --all clears every environment.",
"resolved target by default); `--all` clears every environment.",
)(logout)
app.command()(parse)
app.command()(extract)
Expand Down
4 changes: 2 additions & 2 deletions src/ade_cli/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
False,
"--id-only",
help="Print only the id(s) this run produced, one per line — the "
"piping mode (JOB=$(ade parse -d f.pdf --id-only)). Takes precedence "
"over --json; errors and hints go to stderr.",
"piping mode (JOB=$(ade parse -d f.pdf `--id-only`)). Takes precedence "
"over `--json`; errors and hints go to stderr.",
)

# Machine-readable exit states, shared by every command: pending is a normal
Expand Down
6 changes: 3 additions & 3 deletions src/ade_cli/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,12 @@ def parse(
ctx: typer.Context,
document: Path | None = typer.Option(
None, "-d", "--document", exists=True, dir_okay=False, readable=True,
help="Local document file to parse; pass exactly one of -d/--document or --document-url.",
help="Local document file to parse; pass exactly one of -d/`--document` or `--document-url`.",
),
document_url: str | None = typer.Option(
None, "--document-url",
help="Document URL the server fetches (identity is the URL x params; "
"a re-run dedups even if the remote content changed — --force refreshes).",
"a re-run dedups even if the remote content changed — `--force` refreshes).",
),
model: str = typer.Option(
DEFAULT_MODEL, "--model", help="Parse model registry version."
Expand Down Expand Up @@ -138,7 +138,7 @@ def parse(
"password: always rejected (422) — decrypt PDFs before upload. "
"Example: '{\"inline_markdown\": true, \"blocks\": {\"table\": "
"{\"format\": \"markdown\"}}}'. "
"Merges with --pages; giving pages in both is an error.",
"Merges with `--pages`; giving pages in both is an error.",
),
environment: str | None = typer.Option(
None, "--env",
Expand Down
10 changes: 5 additions & 5 deletions src/ade_cli/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -898,18 +898,18 @@ def view(
crop: bool = typer.Option(
False,
"--crop",
help="With --element-id: render that element's PNG crop instead of "
help="With `--element-id`: render that element's PNG crop instead of "
"the HTML artifact.",
),
open_browser: bool | None = typer.Option(
None,
"--open/--no-open",
help="Open the result in the browser. Default: open when stdout is "
"a terminal; --json runs and piped output never auto-open.",
"a terminal; `--json` runs and piped output never auto-open.",
),
dpi: int | None = typer.Option(
None, "--dpi", min=1,
help=f"Page render dpi (default {DEFAULT_DPI}); with --crop, the crop "
help=f"Page render dpi (default {DEFAULT_DPI}); with `--crop`, the crop "
f"dpi (default {DEFAULT_CROP_DPI}).",
),
pages: str | None = typer.Option(
Expand All @@ -924,11 +924,11 @@ def view(
help="Open via a local server (http://127.0.0.1) instead of file:// "
"— browser zoom then covers every viewer natively. Starts the "
"server if needed; reuses a running one. It retires itself after "
"30 idle minutes, or immediately with --stop-server.",
"30 idle minutes, or immediately with `--stop-server`.",
),
stop_server: bool = typer.Option(
False, "--stop-server",
help="Stop the local viewer server (started by --serve) and exit.",
help="Stop the local viewer server (started by `--serve`) and exit.",
),
sync_viewers: bool = typer.Option(
False, "--sync-viewers", hidden=True,
Expand Down
67 changes: 67 additions & 0 deletions tests/test_help_style.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Style guard for the help surface: inline code is always backticked.

A bare ``--flag`` in help prose is ambiguous for agents and renders as
an em dash plus the flag name on the hosted docs, which generate the
CLI reference from ``help --json``. See docs/agents/writing-style.md.

Scope: every prose field in the help surface. Topic bodies are exempt:
they are pre-formatted terminal layouts (aligned flag columns, literal
example commands) where backticks would corrupt the rendering. Other
inline-code kinds (paths, commands, env vars) stay convention; a test
cannot reliably tell a path from prose, but a long flag is unmistakable.
"""

from __future__ import annotations

import json
import re

import pytest

# A long flag in prose that is not already inside a backtick span.
CODE_SPAN = re.compile(r"`[^`]*`")
BARE_FLAG = re.compile(r"(?<![\w`-])--[a-z][a-z0-9-]*")


@pytest.fixture
def reference(cli) -> dict:
result = cli.invoke("help", "--json")
assert result.exit_code == 0
return json.loads(result.stdout)


def _prose_strings(reference: dict):
"""Yield (locator, text) for every prose string in the help surface."""
yield "description", reference["description"]
for convention in reference["conventions"]:
yield f"conventions[{convention['name']}]", convention["rule"]
for command in reference["commands"]:
where = f"commands[{command['name']}]"
yield f"{where}.summary", command["summary"]
for argument in command["arguments"]:
yield f"{where}.arguments[{argument['name']}]", argument["help"]
for flag in command["flags"]:
yield f"{where}.flags[{flag['flags']}]", flag["help"]
for key in command.get("result", {}).get("keys", []):
yield f"{where}.result[{key['key']}]", key["what"]
note = command.get("result", {}).get("note")
if note:
yield f"{where}.result.note", note
# topics[].body is deliberately absent: pre-formatted terminal text.
for state in reference["exit_states"]:
yield f"exit_states[{state['name']}]", state["meaning"]
for entry in reference["store"]["layout"]:
yield f"store[{entry['path']}]", entry["what"]


def test_flags_in_help_prose_are_backticked(reference):
"""Every --flag mentioned in help prose is wrapped in backticks."""
offenders = []
for locator, text in _prose_strings(reference):
stripped = CODE_SPAN.sub("", text)
for match in BARE_FLAG.findall(stripped):
offenders.append(f"{locator}: bare {match}")
assert not offenders, (
"Bare flags in help prose; wrap them in backticks "
"(docs/agents/writing-style.md):\n" + "\n".join(offenders)
)
Loading