Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ and versions are tracked in the repo-root `VERSION` file.
- Add the optional `base-cli[typer]` integration with `attach_typer()`,
`TyperAdapter`, and `get_typer_command()` so Typer command trees can adopt
the same lifecycle without making Typer a core dependency.
- Add opt-in versioned JSON success/error envelopes, redacted bounded JSON
logs, and public contract helpers for machine-facing integrations.

### Changed

Expand Down
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ Typer applications can opt into the same lifecycle with the optional
for the migration path; Typer remains optional and is never imported by the
core Click integration.

Automation-facing JSON output, errors, and logs are opt-in through the
versioned contracts documented in [`docs/json-contracts.md`](docs/json-contracts.md).
Human output and Click error behavior remain the default.

## Design Goals

CLI tools should be easy to write, but not magical. A command should be
Expand Down Expand Up @@ -453,11 +457,14 @@ Every `base_cli.App` command gets these options:
- `--keep-temp`: preserve the run's temp directory after command completion.
- `--log-file <path>`: write the persistent log to a specific file.
- `--version`: shown when the `App` was created with a version.
- `--json`: opt-in machine output, when `LifecycleOptions.json` is enabled;
emits the versioned envelopes described in [`docs/json-contracts.md`](docs/json-contracts.md).

`LifecycleOptions()` preserves this default set. Its `debug`, `quiet`,
`environment`, `config`, `keep_temp`, `log_file`, and `version` fields are
enabled by default; `dry_run` is opt-in. Set one field to `None` to disable it,
or replace it with a `LifecycleOption` to rename and configure it independently:
enabled by default; `dry_run` and `json` are opt-in. Set one field to `None` to
disable it, or replace it with a `LifecycleOption` to rename and configure it
independently:

```python
lifecycle_options = base_cli.LifecycleOptions(
Expand Down Expand Up @@ -485,6 +492,14 @@ app = base_cli.App(
)
```

For scripts, add the JSON option explicitly:

```python
lifecycle_options = base_cli.LifecycleOptions(
json=base_cli.LifecycleOption("--json"),
)
```

`LifecycleOption` accepts Click declarations followed by the keyword-only
`name`, `help`, `metavar`, `envvar`, `show_envvar`, `show_default`, `hidden`,
and `default` presentation and value-source settings. When `name` is omitted,
Expand Down Expand Up @@ -528,7 +543,7 @@ def inspect(click_ctx: click.Context) -> None:
values = base_cli.get_lifecycle_values(click_ctx)
assert isinstance(values, base_cli.LifecycleValues)
assert values is click_ctx.meta[base_cli.LIFECYCLE_META_KEY]
print(values.environment, values.debug, values.dry_run)
print(values.environment, values.debug, values.dry_run, values.json)
```

The metadata record, rather than `click.Context.obj`, carries values between a
Expand Down
73 changes: 73 additions & 0 deletions docs/json-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# JSON contracts

Machine-facing output is opt-in. Configure a JSON lifecycle option on an app
when a command is intended for scripts or automation:

```python
import base_cli

app = base_cli.App(
name="example",
lifecycle_options=base_cli.LifecycleOptions(
json=base_cli.LifecycleOption("--json"),
),
)
```

`example --json` captures command stdout and emits exactly one success or error
envelope on stdout. Logs remain on stderr. Human mode, including the default
Click error rendering and command stdout behavior, is unchanged.

## Output and errors

Both envelopes use `schema_version: 1` and stable fields:

```json
{
"schema_version": 1,
"schema": "base-cli.output",
"code": "ok",
"type": "success",
"message": "Success",
"details": {"exit_code": 0, "stdout": "hello\n"},
"run_id": "20260804T192202_dd231351"
}
```

Failures use `schema: "base-cli.error"`, `type: "error"`, and a deterministic
`code` derived from the lifecycle outcome (`usage_error`, `click_error`,
`aborted`, `interrupted`, `unexpected_error`, and so on). `details` always
contains the numeric `exit_code` and captured command stdout. A command's
human output is represented as a JSON string, so it cannot introduce prose or
ANSI escapes as a second stdout record.

`run_id` is the lifecycle run identifier when startup reached a runtime
context, otherwise it is `null`. Unexpected failures intentionally expose only
the generic message `Unexpected internal error.`; diagnostics stay in logs.

The lower-level `success_envelope()`, `error_envelope()`, `dumps_envelope()`,
and `redact_json_value()` helpers are public for commands that need to publish
their own structured `details` records. Secret-looking keys (`token`,
`password`, `secret`, `api_key`, and `authorization`) and credential-bearing
URLs are redacted recursively.

## JSON logs

Pass `json_logs=True` and the run identifier to `configure_logger()` when an
integration needs structured logs without enabling machine output:

```python
logger = base_cli.configure_logger(
"example",
log_file,
debug=True,
json_logs=True,
run_id="run-123",
)
```

Each line is a JSON object with `schema_version`, `schema`, `timestamp` (UTC),
`level`, `logger`, `message`, and `run_id`. Messages are redacted and capped at
8 KiB; persistent files retain base-cli's owner-only permissions and JSON mode
bounds default-log retention to the most recent 20 files (or the explicit
`max_log_files` setting). JSON logs never use terminal color codes.
25 changes: 24 additions & 1 deletion lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def _resolve_version() -> str:

__version__ = _resolve_version()

from . import command_filters, command_protocol, history, testing
from . import command_filters, command_protocol, history, json_contracts, testing
from .attachment import (
AttachmentAdapter,
AttachmentContextFactory,
Expand Down Expand Up @@ -74,6 +74,18 @@ def _resolve_version() -> str:
from .errors import ConfigurationError
from .exit_codes import ExitCode
from .inspection import inspection_envelope, render_inspection_json
from .json_contracts import (
JSON_CONTRACT_VERSION,
JSON_ERROR_SCHEMA,
JSON_LOG_SCHEMA,
JSON_OUTPUT_SCHEMA,
JsonLogFormatter,
MAX_JSON_LOG_MESSAGE_LENGTH,
dumps_envelope,
error_envelope,
redact_json_value,
success_envelope,
)
from .logging import configure_logger, log_critical, log_debug, log_error, log_info, log_warning
from .lifecycle_options import (
LIFECYCLE_META_KEY,
Expand Down Expand Up @@ -144,8 +156,17 @@ def _resolve_version() -> str:
"command_filters",
"command_matches",
"command_protocol",
"json_contracts",
"JSON_CONTRACT_VERSION",
"JSON_ERROR_SCHEMA",
"JSON_LOG_SCHEMA",
"JSON_OUTPUT_SCHEMA",
"JsonLogFormatter",
"MAX_JSON_LOG_MESSAGE_LENGTH",
"dumps_envelope",
"dumps_record",
"dumps_records",
"error_envelope",
"history",
"inspection_envelope",
"render_inspection_json",
Expand Down Expand Up @@ -181,8 +202,10 @@ def _resolve_version() -> str:
"render_document",
"render_records",
"register_record_schema",
"redact_json_value",
"resolve_output_format",
"run_app",
"success_envelope",
"RuntimeBinding",
"ServicesT",
"HistoryWriter",
Expand Down
Loading