From 7d26b668450e33095a0ddbf0e2209f762eb127fb Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:07:23 -0500 Subject: [PATCH 1/3] feat(version): stabilize JSON output contract Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/core.md | 88 +++++++++++++- src/specify_cli/_json_output.py | 42 +++++++ src/specify_cli/command_version.py | 76 ++++++++++--- tests/specify_cli/test_command_version.py | 133 ++++++++++++++++++---- 4 files changed, 294 insertions(+), 45 deletions(-) create mode 100644 src/specify_cli/_json_output.py diff --git a/docs/reference/core.md b/docs/reference/core.md index dc9255e697..6de5f0f39c 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -102,11 +102,93 @@ To inspect local CLI capabilities without checking the network: ```bash specify version --features -specify version --features --json ``` -The JSON form is intended for scripts and coding agents that need to choose a -workflow based on the installed CLI's supported features. +For the stable machine-readable contract, use: + +```bash +specify version --json +``` + +`specify version --features --json` remains a compatibility alias and emits +the same complete payload. In JSON mode, `--features` does not filter the +result. + +Successful commands write exactly one JSON object to stdout, write nothing to +stderr, and exit with status 0: + +```json +{ + "schema_version": "1.0", + "command": "version", + "ok": true, + "result": { + "cli_version": "1.0.14", + "runtime": { + "python": "3.13.1", + "openssl": "OpenSSL 3.4.0 22 Oct 2024" + }, + "system": { + "platform": "Darwin", + "architecture": "arm64", + "os_version": "Darwin Kernel Version 24.3.0" + }, + "features": { + "controlled_multi_install_integrations": true, + "integration_use_command": true, + "multi_install_safe_registry_metadata": true, + "integration_upgrade_command": true, + "self_check_command": true, + "workflow_catalog": true, + "bundled_templates": true + } + }, + "warnings": [] +} +``` + +| Field | Definition | +| ----- | ---------- | +| `schema_version` | Version of this JSON contract. The initial stable contract is `"1.0"`. | +| `command` | Command that produced the envelope; always `"version"` here. | +| `ok` | `true` for success and `false` for failure. | +| `result.cli_version` | Installed Spec Kit CLI version. | +| `result.runtime.python` | Python runtime version. | +| `result.runtime.openssl` | OpenSSL runtime loaded by Python, or JSON `null` when the `ssl` module or its version value is unavailable. The field is never omitted. | +| `result.system.platform` | Operating-system family reported by Python. | +| `result.system.architecture` | Machine architecture reported by Python. | +| `result.system.os_version` | Operating-system version reported by Python. | +| `result.features` | Complete map of local CLI capability names to booleans. | +| `warnings` | Reserved list of non-fatal warnings; empty in schema version 1.0. | + +If version information cannot be collected, the command exits nonzero, leaves +stdout empty, and writes exactly one failure envelope to stderr. Unexpected +exceptions use a sanitized message: exception text, tracebacks, environment +values, paths, and terminal formatting are not included. + +```json +{ + "schema_version": "1.0", + "command": "version", + "ok": false, + "error": { + "code": "internal_error", + "message": "Unable to collect version information.", + "details": {} + } +} +``` + +| Failure field | Definition | +| ------------- | ---------- | +| `error.code` | Stable machine-readable failure category. | +| `error.message` | Safe human-readable summary. | +| `error.details` | Structured failure context. It is empty for sanitized unexpected failures. | + +Earlier releases exposed a provisional `{"version": ..., "features": ...}` +object only through `--features --json` and rejected `--json` by itself. Schema +version 1.0 intentionally replaces that provisional shape with the complete +envelope above and makes `specify version --json` canonical. A quick version check is also available via: diff --git a/src/specify_cli/_json_output.py b/src/specify_cli/_json_output.py new file mode 100644 index 0000000000..4ed44e264a --- /dev/null +++ b/src/specify_cli/_json_output.py @@ -0,0 +1,42 @@ +"""Small internal helpers for versioned CLI JSON envelopes.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + + +def success_envelope( + command: str, + result: Mapping[str, Any], + *, + warnings: Sequence[Mapping[str, Any] | str] = (), +) -> dict[str, Any]: + """Build a successful version 1.0 CLI JSON envelope.""" + return { + "schema_version": "1.0", + "command": command, + "ok": True, + "result": dict(result), + "warnings": list(warnings), + } + + +def failure_envelope( + command: str, + *, + code: str, + message: str, + details: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Build a failed version 1.0 CLI JSON envelope.""" + return { + "schema_version": "1.0", + "command": command, + "ok": False, + "error": { + "code": code, + "message": message, + "details": dict(details or {}), + }, + } diff --git a/src/specify_cli/command_version.py b/src/specify_cli/command_version.py index f09ad73acd..eeb8946f8b 100644 --- a/src/specify_cli/command_version.py +++ b/src/specify_cli/command_version.py @@ -10,6 +10,10 @@ from rich.table import Table from ._console import console, show_banner +from ._json_output import failure_envelope, success_envelope + +_COMMAND_NAME = "version" +_INTERNAL_ERROR_MESSAGE = "Unable to collect version information." def _feature_capabilities() -> dict[str, bool]: @@ -25,6 +29,39 @@ def _feature_capabilities() -> dict[str, bool]: } +def _openssl_version() -> str | None: + """Return the loaded OpenSSL version, or None when unavailable.""" + try: + import ssl + except ImportError: + return None + + value = getattr(ssl, "OPENSSL_VERSION", None) + return value if isinstance(value, str) and value else None + + +def _json_result(cli_version: str) -> dict[str, object]: + """Collect the complete machine-readable version result.""" + return { + "cli_version": cli_version, + "runtime": { + "python": platform.python_version(), + "openssl": _openssl_version(), + }, + "system": { + "platform": platform.system(), + "architecture": platform.machine(), + "os_version": platform.version(), + }, + "features": _feature_capabilities(), + } + + +def _serialize_json(payload: dict[str, object]) -> str: + """Serialize one JSON envelope without terminal formatting.""" + return json.dumps(payload, indent=2) + + def version( features: bool = typer.Option( False, @@ -34,25 +71,35 @@ def version( json_output: bool = typer.Option( False, "--json", - help="Emit feature capabilities as JSON. Requires --features.", + help="Emit complete version information as JSON.", ), ) -> None: """Display version and system information.""" from . import get_speckit_version - cli_version = get_speckit_version() - - if json_output and not features: - console.print("[red]Error:[/red] --json requires --features.") - raise typer.Exit(1) + if json_output: + try: + payload = success_envelope( + _COMMAND_NAME, + _json_result(get_speckit_version()), + ) + rendered = _serialize_json(payload) + except Exception: # noqa: BLE001 + # JSON mode must normalize every unexpected command failure. + failure = failure_envelope( + _COMMAND_NAME, + code="internal_error", + message=_INTERNAL_ERROR_MESSAGE, + ) + typer.echo(_serialize_json(failure), err=True) + raise typer.Exit(1) + + typer.echo(rendered) + return + cli_version = get_speckit_version() if features: capabilities = _feature_capabilities() - if json_output: - payload = {"version": cli_version, "features": capabilities} - console.print(json.dumps(payload, indent=2)) - return - console.print(f"Spec Kit CLI: {cli_version}") console.print() console.print("Features:") @@ -77,12 +124,7 @@ def version( # reports (#4433) hinge on which OpenSSL is in play, and on Windows it is # not obvious from the outside, so surface it here. An interpreter built # without the ssl extension skips the row rather than failing the command. - try: - import ssl - - openssl_version = getattr(ssl, "OPENSSL_VERSION", "") - except ImportError: - openssl_version = "" + openssl_version = _openssl_version() if openssl_version: info_table.add_row("OpenSSL", openssl_version) diff --git a/tests/specify_cli/test_command_version.py b/tests/specify_cli/test_command_version.py index 58a8e7c051..ea14457c55 100644 --- a/tests/specify_cli/test_command_version.py +++ b/tests/specify_cli/test_command_version.py @@ -10,9 +10,18 @@ from specify_cli import app - runner = CliRunner() +EXPECTED_FEATURES = { + "controlled_multi_install_integrations": True, + "integration_use_command": True, + "multi_install_safe_registry_metadata": True, + "integration_upgrade_command": True, + "self_check_command": True, + "workflow_catalog": True, + "bundled_templates": True, +} + class TestVersionCommand: """Test the `specify version` subcommand.""" @@ -29,32 +38,106 @@ def test_version_features_text(self): assert "- integration use command: yes" in result.output assert "- self check command: yes" in result.output - def test_version_features_json(self): - """specify version --features --json prints machine-readable capabilities.""" + def test_version_json_emits_complete_stable_envelope(self): + """specify version --json emits the complete versioned contract.""" + with ( + patch("specify_cli.get_speckit_version", return_value="1.2.3"), + patch( + "specify_cli.command_version.platform.python_version", + return_value="3.13.1", + ), + patch( + "specify_cli.command_version.platform.system", + return_value="ExampleOS", + ), + patch( + "specify_cli.command_version.platform.machine", + return_value="example64", + ), + patch( + "specify_cli.command_version.platform.version", + return_value="ExampleOS 4.5", + ), + patch( + "specify_cli.command_version._openssl_version", + return_value="OpenSSL 3.4.0", + ), + ): + result = runner.invoke(app, ["version", "--json"]) + + expected = { + "schema_version": "1.0", + "command": "version", + "ok": True, + "result": { + "cli_version": "1.2.3", + "runtime": { + "python": "3.13.1", + "openssl": "OpenSSL 3.4.0", + }, + "system": { + "platform": "ExampleOS", + "architecture": "example64", + "os_version": "ExampleOS 4.5", + }, + "features": EXPECTED_FEATURES, + }, + "warnings": [], + } + assert result.exit_code == 0 + assert result.stdout == f"{json.dumps(expected, indent=2)}\n" + assert result.stderr == "" + assert json.loads(result.stdout) == expected + + def test_version_features_json_is_exact_alias(self): + """--features does not filter JSON output or change its bytes.""" + with patch("specify_cli.get_speckit_version", return_value="1.2.3"): + canonical = runner.invoke(app, ["version", "--json"]) + compatibility = runner.invoke(app, ["version", "--features", "--json"]) + + assert canonical.exit_code == 0 + assert compatibility.exit_code == 0 + assert compatibility.stdout == canonical.stdout + assert compatibility.stderr == canonical.stderr == "" + + def test_version_json_uses_null_when_openssl_unavailable(self, monkeypatch): + """Missing ssl is represented as a stable JSON null value.""" + monkeypatch.setitem(sys.modules, "ssl", None) with patch("specify_cli.get_speckit_version", return_value="1.2.3"): - result = runner.invoke(app, ["version", "--features", "--json"]) + result = runner.invoke(app, ["version", "--json"]) assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload == { - "version": "1.2.3", - "features": { - "controlled_multi_install_integrations": True, - "integration_use_command": True, - "multi_install_safe_registry_metadata": True, - "integration_upgrade_command": True, - "self_check_command": True, - "workflow_catalog": True, - "bundled_templates": True, + assert result.stderr == "" + assert json.loads(result.stdout)["result"]["runtime"]["openssl"] is None + + def test_version_json_sanitizes_unexpected_failures(self): + """Unexpected failures emit one safe envelope and no traceback.""" + unsafe = ( + "\x1b[31mSECRET_TOKEN=do-not-print /Users/example/private/project\x1b[0m" + ) + with patch( + "specify_cli.get_speckit_version", + side_effect=RuntimeError(unsafe), + ): + result = runner.invoke(app, ["version", "--json"]) + + expected = { + "schema_version": "1.0", + "command": "version", + "ok": False, + "error": { + "code": "internal_error", + "message": "Unable to collect version information.", + "details": {}, }, } - - def test_version_json_requires_features(self): - """specify version --json is rejected until a JSON surface exists.""" - result = runner.invoke(app, ["version", "--json"]) - - assert result.exit_code != 0 - assert "--json requires --features" in result.output + assert result.exit_code == 1 + assert result.stdout == "" + assert result.stderr == f"{json.dumps(expected, indent=2)}\n" + assert json.loads(result.stderr) == expected + assert unsafe not in result.stderr + assert "\x1b" not in result.stderr + assert "Traceback" not in result.stderr def test_version_reports_openssl_runtime(self): """specify version reports the OpenSSL runtime the interpreter loaded. @@ -104,10 +187,10 @@ def test_version_skips_openssl_row_when_version_attr_missing(self, monkeypatch): assert "OpenSSL" not in result.output def test_version_features_never_touches_ssl(self, monkeypatch): - """--features/--json return early and must not require ssl at all.""" + """The focused human feature view does not require ssl.""" monkeypatch.setitem(sys.modules, "ssl", None) with patch("specify_cli.get_speckit_version", return_value="1.2.3"): - result = runner.invoke(app, ["version", "--features", "--json"]) + result = runner.invoke(app, ["version", "--features"]) assert result.exit_code == 0 - assert json.loads(result.output)["version"] == "1.2.3" + assert "Spec Kit CLI: 1.2.3" in result.output From 82bae7e095e195a0538b2fedfc1cc56fca4aad5e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:22:04 -0500 Subject: [PATCH 2/3] refactor(version): emit direct JSON payload Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/core.md | 83 ++--------------------- src/specify_cli/_json_output.py | 42 ------------ src/specify_cli/command_version.py | 19 +++--- tests/specify_cli/test_command_version.py | 37 ++++------ 4 files changed, 27 insertions(+), 154 deletions(-) delete mode 100644 src/specify_cli/_json_output.py diff --git a/docs/reference/core.md b/docs/reference/core.md index 6de5f0f39c..ecdf68e439 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -104,7 +104,8 @@ To inspect local CLI capabilities without checking the network: specify version --features ``` -For the stable machine-readable contract, use: +To print complete version, runtime, system, and feature information as JSON, +use: ```bash specify version --json @@ -112,83 +113,9 @@ specify version --json `specify version --features --json` remains a compatibility alias and emits the same complete payload. In JSON mode, `--features` does not filter the -result. - -Successful commands write exactly one JSON object to stdout, write nothing to -stderr, and exit with status 0: - -```json -{ - "schema_version": "1.0", - "command": "version", - "ok": true, - "result": { - "cli_version": "1.0.14", - "runtime": { - "python": "3.13.1", - "openssl": "OpenSSL 3.4.0 22 Oct 2024" - }, - "system": { - "platform": "Darwin", - "architecture": "arm64", - "os_version": "Darwin Kernel Version 24.3.0" - }, - "features": { - "controlled_multi_install_integrations": true, - "integration_use_command": true, - "multi_install_safe_registry_metadata": true, - "integration_upgrade_command": true, - "self_check_command": true, - "workflow_catalog": true, - "bundled_templates": true - } - }, - "warnings": [] -} -``` - -| Field | Definition | -| ----- | ---------- | -| `schema_version` | Version of this JSON contract. The initial stable contract is `"1.0"`. | -| `command` | Command that produced the envelope; always `"version"` here. | -| `ok` | `true` for success and `false` for failure. | -| `result.cli_version` | Installed Spec Kit CLI version. | -| `result.runtime.python` | Python runtime version. | -| `result.runtime.openssl` | OpenSSL runtime loaded by Python, or JSON `null` when the `ssl` module or its version value is unavailable. The field is never omitted. | -| `result.system.platform` | Operating-system family reported by Python. | -| `result.system.architecture` | Machine architecture reported by Python. | -| `result.system.os_version` | Operating-system version reported by Python. | -| `result.features` | Complete map of local CLI capability names to booleans. | -| `warnings` | Reserved list of non-fatal warnings; empty in schema version 1.0. | - -If version information cannot be collected, the command exits nonzero, leaves -stdout empty, and writes exactly one failure envelope to stderr. Unexpected -exceptions use a sanitized message: exception text, tracebacks, environment -values, paths, and terminal formatting are not included. - -```json -{ - "schema_version": "1.0", - "command": "version", - "ok": false, - "error": { - "code": "internal_error", - "message": "Unable to collect version information.", - "details": {} - } -} -``` - -| Failure field | Definition | -| ------------- | ---------- | -| `error.code` | Stable machine-readable failure category. | -| `error.message` | Safe human-readable summary. | -| `error.details` | Structured failure context. It is empty for sanitized unexpected failures. | - -Earlier releases exposed a provisional `{"version": ..., "features": ...}` -object only through `--features --json` and rejected `--json` by itself. Schema -version 1.0 intentionally replaces that provisional shape with the complete -envelope above and makes `specify version --json` canonical. +result. If OpenSSL information is unavailable, `runtime.openssl` is `null`. +Successful JSON output is written only to stdout. Failures leave stdout empty +and write one sanitized JSON error object to stderr. A quick version check is also available via: diff --git a/src/specify_cli/_json_output.py b/src/specify_cli/_json_output.py deleted file mode 100644 index 4ed44e264a..0000000000 --- a/src/specify_cli/_json_output.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Small internal helpers for versioned CLI JSON envelopes.""" - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from typing import Any - - -def success_envelope( - command: str, - result: Mapping[str, Any], - *, - warnings: Sequence[Mapping[str, Any] | str] = (), -) -> dict[str, Any]: - """Build a successful version 1.0 CLI JSON envelope.""" - return { - "schema_version": "1.0", - "command": command, - "ok": True, - "result": dict(result), - "warnings": list(warnings), - } - - -def failure_envelope( - command: str, - *, - code: str, - message: str, - details: Mapping[str, Any] | None = None, -) -> dict[str, Any]: - """Build a failed version 1.0 CLI JSON envelope.""" - return { - "schema_version": "1.0", - "command": command, - "ok": False, - "error": { - "code": code, - "message": message, - "details": dict(details or {}), - }, - } diff --git a/src/specify_cli/command_version.py b/src/specify_cli/command_version.py index eeb8946f8b..c0ae9efba8 100644 --- a/src/specify_cli/command_version.py +++ b/src/specify_cli/command_version.py @@ -10,9 +10,7 @@ from rich.table import Table from ._console import console, show_banner -from ._json_output import failure_envelope, success_envelope -_COMMAND_NAME = "version" _INTERNAL_ERROR_MESSAGE = "Unable to collect version information." @@ -79,18 +77,17 @@ def version( if json_output: try: - payload = success_envelope( - _COMMAND_NAME, - _json_result(get_speckit_version()), - ) + payload = _json_result(get_speckit_version()) rendered = _serialize_json(payload) except Exception: # noqa: BLE001 # JSON mode must normalize every unexpected command failure. - failure = failure_envelope( - _COMMAND_NAME, - code="internal_error", - message=_INTERNAL_ERROR_MESSAGE, - ) + failure = { + "error": { + "code": "internal_error", + "message": _INTERNAL_ERROR_MESSAGE, + "details": {}, + } + } typer.echo(_serialize_json(failure), err=True) raise typer.Exit(1) diff --git a/tests/specify_cli/test_command_version.py b/tests/specify_cli/test_command_version.py index ea14457c55..d249b3c354 100644 --- a/tests/specify_cli/test_command_version.py +++ b/tests/specify_cli/test_command_version.py @@ -38,8 +38,8 @@ def test_version_features_text(self): assert "- integration use command: yes" in result.output assert "- self check command: yes" in result.output - def test_version_json_emits_complete_stable_envelope(self): - """specify version --json emits the complete versioned contract.""" + def test_version_json_emits_complete_stable_payload(self): + """specify version --json emits the complete machine-readable result.""" with ( patch("specify_cli.get_speckit_version", return_value="1.2.3"), patch( @@ -66,23 +66,17 @@ def test_version_json_emits_complete_stable_envelope(self): result = runner.invoke(app, ["version", "--json"]) expected = { - "schema_version": "1.0", - "command": "version", - "ok": True, - "result": { - "cli_version": "1.2.3", - "runtime": { - "python": "3.13.1", - "openssl": "OpenSSL 3.4.0", - }, - "system": { - "platform": "ExampleOS", - "architecture": "example64", - "os_version": "ExampleOS 4.5", - }, - "features": EXPECTED_FEATURES, + "cli_version": "1.2.3", + "runtime": { + "python": "3.13.1", + "openssl": "OpenSSL 3.4.0", }, - "warnings": [], + "system": { + "platform": "ExampleOS", + "architecture": "example64", + "os_version": "ExampleOS 4.5", + }, + "features": EXPECTED_FEATURES, } assert result.exit_code == 0 assert result.stdout == f"{json.dumps(expected, indent=2)}\n" @@ -108,10 +102,10 @@ def test_version_json_uses_null_when_openssl_unavailable(self, monkeypatch): assert result.exit_code == 0 assert result.stderr == "" - assert json.loads(result.stdout)["result"]["runtime"]["openssl"] is None + assert json.loads(result.stdout)["runtime"]["openssl"] is None def test_version_json_sanitizes_unexpected_failures(self): - """Unexpected failures emit one safe envelope and no traceback.""" + """Unexpected failures emit one safe error object and no traceback.""" unsafe = ( "\x1b[31mSECRET_TOKEN=do-not-print /Users/example/private/project\x1b[0m" ) @@ -122,9 +116,6 @@ def test_version_json_sanitizes_unexpected_failures(self): result = runner.invoke(app, ["version", "--json"]) expected = { - "schema_version": "1.0", - "command": "version", - "ok": False, "error": { "code": "internal_error", "message": "Unable to collect version information.", From b3ed2a727522955ef2825a06e39e028496aaa547 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:32:04 -0500 Subject: [PATCH 3/3] docs(version): describe supported JSON option combination Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/core.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/core.md b/docs/reference/core.md index ecdf68e439..f713ed5eaa 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -111,9 +111,9 @@ use: specify version --json ``` -`specify version --features --json` remains a compatibility alias and emits -the same complete payload. In JSON mode, `--features` does not filter the -result. If OpenSSL information is unavailable, `runtime.openssl` is `null`. +Combining `--features` and `--json` emits the same complete JSON output; +`--features` does not filter the result in JSON mode. If OpenSSL information +is unavailable, `runtime.openssl` is `null`. Successful JSON output is written only to stdout. Failures leave stdout empty and write one sanitized JSON error object to stderr.