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
15 changes: 12 additions & 3 deletions docs/reference/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
73 changes: 56 additions & 17 deletions src/specify_cli/command_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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,
Expand All @@ -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:")
Expand All @@ -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)

Expand Down
124 changes: 99 additions & 25 deletions tests/specify_cli/test_command_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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.
Expand Down Expand Up @@ -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
Loading