diff --git a/docs/reference/core.md b/docs/reference/core.md index d94bc04621..c4d26ec66c 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -110,11 +110,20 @@ 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. +To print complete version, runtime, system, and feature information as JSON, +use: + +```bash +specify version --json +``` + +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. A quick version check is also available via: diff --git a/src/specify_cli/command_version.py b/src/specify_cli/command_version.py index f09ad73acd..c0ae9efba8 100644 --- a/src/specify_cli/command_version.py +++ b/src/specify_cli/command_version.py @@ -11,6 +11,8 @@ from ._console import console, show_banner +_INTERNAL_ERROR_MESSAGE = "Unable to collect version information." + def _feature_capabilities() -> dict[str, bool]: """Return stable local CLI capability flags for humans and agents.""" @@ -25,6 +27,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 +69,34 @@ 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 = _json_result(get_speckit_version()) + rendered = _serialize_json(payload) + except Exception: # noqa: BLE001 + # JSON mode must normalize every unexpected command failure. + failure = { + "error": { + "code": "internal_error", + "message": _INTERNAL_ERROR_MESSAGE, + "details": {}, + } + } + 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 +121,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..d249b3c354 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,97 @@ 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_payload(self): + """specify version --json emits the complete machine-readable result.""" + 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 = { + "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, + } + 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"): - result = runner.invoke(app, ["version", "--features", "--json"]) + 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", "--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)["runtime"]["openssl"] is None + + def test_version_json_sanitizes_unexpected_failures(self): + """Unexpected failures emit one safe error object 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 = { + "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 +178,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