From 805c07bab5933e49e201f63ca286506f7d700e37 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Sun, 15 Feb 2026 21:04:04 +0000 Subject: [PATCH 1/4] Optimize CLI output defaults and compact JSON (#94) --- docs/SKILL.md | 15 +- src/specleft/commands/contract.py | 28 +-- src/specleft/commands/contracts/payloads.py | 19 ++ src/specleft/commands/contracts/runner.py | 12 +- src/specleft/commands/coverage.py | 53 ++++- src/specleft/commands/doctor.py | 48 ++-- src/specleft/commands/enforce.py | 51 ++++- src/specleft/commands/features.py | 212 +++++++++++------- src/specleft/commands/guide.py | 16 +- src/specleft/commands/init.py | 68 +++--- src/specleft/commands/next.py | 35 +-- src/specleft/commands/output.py | 52 +++++ src/specleft/commands/plan.py | 26 ++- src/specleft/commands/skill.py | 28 +-- src/specleft/commands/status.py | 65 ++++-- src/specleft/commands/test.py | 194 +++++++++------- src/specleft/templates/skill_template.py | 15 +- .../test_feature-1-planning-mode.py | 2 +- .../test_feature-2-specification-format.py | 7 - .../test_feature-3-canonical-json-output.py | 6 +- ...st_feature-4-status-coverage-inspection.py | 11 +- .../test_feature-6-ci-experience-messaging.py | 4 +- ...ature-7-autonomous-agent-test-execution.py | 18 +- .../test_feature-9-cli-feature-authoring.py | 2 + tests/commands/test_doctor.py | 4 +- tests/commands/test_enforce_integration.py | 28 ++- tests/commands/test_features.py | 29 ++- tests/commands/test_features_add.py | 17 +- tests/commands/test_guide.py | 2 +- tests/commands/test_init.py | 28 +-- tests/commands/test_plan.py | 28 ++- tests/commands/test_status.py | 16 +- tests/commands/test_test_report.py | 14 +- tests/commands/test_test_skeleton.py | 72 ++++-- tests/commands/test_test_stub.py | 41 ++-- 35 files changed, 832 insertions(+), 434 deletions(-) create mode 100644 src/specleft/commands/output.py diff --git a/docs/SKILL.md b/docs/SKILL.md index f6a3c64..11da8b5 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -1,13 +1,22 @@ # SpecLeft CLI Reference +## Setup +`export SPECLEFT_COMPACT=1` +All commands below run in compact mode. + ## Workflow -1. specleft next --format json +1. specleft next --limit 1 2. Implement test logic -3. specleft features validate --format json -4. specleft skill verify --format json +3. specleft features validate +4. specleft skill verify 5. pytest 6. Repeat +## Quick checks +- Validation: check exit code first, parse JSON only on failure +- Coverage: `specleft coverage --threshold 100` and check exit code +- Status: `specleft status` for progress snapshots + ## Safety - Always `--dry-run` before writing files - Never use `--force` unless explicitly requested diff --git a/src/specleft/commands/contract.py b/src/specleft/commands/contract.py index 3d74490..d346900 100644 --- a/src/specleft/commands/contract.py +++ b/src/specleft/commands/contract.py @@ -5,11 +5,11 @@ from __future__ import annotations -import json import sys import click +from specleft.commands.output import json_dumps, resolve_output_format from specleft.utils.messaging import print_support_footer from specleft.commands.contracts.payloads import ( build_contract_payload, @@ -30,18 +30,19 @@ "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") @click.pass_context -def contract(ctx: click.Context, format_type: str) -> None: +def contract(ctx: click.Context, format_type: str | None, pretty: bool) -> None: """Agent contract commands.""" if ctx.invoked_subcommand is not None: return + selected_format = resolve_output_format(format_type) payload = build_contract_payload() - if format_type == "json": - click.echo(json.dumps(payload, indent=2)) + if selected_format == "json": + click.echo(json_dumps(payload, pretty=pretty)) else: print_contract_table(payload) @@ -51,19 +52,20 @@ def contract(ctx: click.Context, format_type: str) -> None: "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option("--verbose", is_flag=True, help="Show detailed results for each check.") -def contract_test(format_type: str, verbose: bool) -> None: +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") +def contract_test(format_type: str | None, verbose: bool, pretty: bool) -> None: """Verify SpecLeft Agent Contract guarantees.""" - if format_type == "json": + selected_format = resolve_output_format(format_type) + if selected_format == "json": passed, checks, errors = run_contract_tests(verbose=verbose) payload = build_contract_test_payload( passed=passed, checks=checks, errors=errors ) - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) else: click.echo("SpecLeft Agent Contract Tests") click.echo("━" * 44) diff --git a/src/specleft/commands/contracts/payloads.py b/src/specleft/commands/contracts/payloads.py index 4588a37..e9e0b63 100644 --- a/src/specleft/commands/contracts/payloads.py +++ b/src/specleft/commands/contracts/payloads.py @@ -9,6 +9,19 @@ from specleft.commands.contracts.types import ContractCheckResult +def _fix_command_for_check(check_name: str) -> str | None: + mapping = { + "dry_run_no_writes": "specleft test skeleton --dry-run --format json", + "no_implicit_writes": "specleft test skeleton --format table", + "existing_tests_not_modified_by_default": "specleft test skeleton --force --format table", + "validation_non_destructive": "specleft features validate --format json", + "json_supported_globally": "specleft guide --format json", + "json_schema_valid": "specleft contract --format json", + "exit_codes_correct": "specleft test skeleton --format table", + } + return mapping.get(check_name) + + def build_contract_payload() -> dict[str, object]: return { "contract_version": CONTRACT_VERSION, @@ -64,6 +77,12 @@ def build_contract_test_payload( "name": check.name, "status": check.status, **({"message": check.message} if check.message else {}), + **( + {"fix_command": _fix_command_for_check(check.name)} + if check.status == "fail" + and _fix_command_for_check(check.name) is not None + else {} + ), } for check in checks ], diff --git a/src/specleft/commands/contracts/runner.py b/src/specleft/commands/contracts/runner.py index 9820950..15d0fb0 100644 --- a/src/specleft/commands/contracts/runner.py +++ b/src/specleft/commands/contracts/runner.py @@ -80,7 +80,9 @@ def _record_check(result: ContractCheckResult) -> None: ) ) - cancel_result = runner.invoke(cli, ["test", "skeleton"], input="n\n") + cancel_result = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="n\n" + ) cancel_pass = cancel_result.exit_code == 2 and not Path("tests").exists() _record_check( ContractCheckResult( @@ -93,7 +95,9 @@ def _record_check(result: ContractCheckResult) -> None: ) ) - create_result = runner.invoke(cli, ["test", "skeleton"], input="y\n") + create_result = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="y\n" + ) generated_file = Path("tests/auth/test_login.py") created = create_result.exit_code == 0 and generated_file.exists() _record_check( @@ -115,7 +119,9 @@ def _record_check(result: ContractCheckResult) -> None: if created: snapshot = record_file_snapshot(root) - rerun_result = runner.invoke(cli, ["test", "skeleton"]) + rerun_result = runner.invoke( + cli, ["test", "skeleton", "--format", "table"] + ) unchanged = rerun_result.exit_code == 0 and compare_file_snapshot( root, snapshot ) diff --git a/src/specleft/commands/coverage.py b/src/specleft/commands/coverage.py index 1f106e0..4cb02dc 100644 --- a/src/specleft/commands/coverage.py +++ b/src/specleft/commands/coverage.py @@ -5,7 +5,6 @@ from __future__ import annotations -import json import sys from datetime import datetime from pathlib import Path @@ -18,6 +17,7 @@ format_execution_time_key, render_badge_svg, ) +from specleft.commands.output import json_dumps, resolve_output_format from specleft.commands.status import build_status_entries from specleft.commands.types import ( CoverageMetrics, @@ -150,6 +150,32 @@ def _build_group_payload( } +def _build_threshold_json( + metrics: CoverageMetrics, *, threshold: int +) -> dict[str, object]: + overall = metrics.overall.percent + passed = overall is not None and overall >= threshold + payload: dict[str, object] = { + "passed": passed, + "overall": overall, + "threshold": threshold, + } + if passed: + return payload + + below_threshold: list[dict[str, object]] = [] + for feature_id, tally in metrics.by_feature.items(): + feature_percent = format_coverage_percent(tally.implemented, tally.total) + if feature_percent is None or feature_percent < threshold: + below_threshold.append( + {"feature_id": feature_id, "coverage": feature_percent} + ) + payload["below_threshold"] = sorted( + below_threshold, key=lambda item: str(item["feature_id"]) + ) + return payload + + def _print_coverage_table(entries: list[ScenarioStatusEntry]) -> None: metrics = _build_coverage_metrics(entries) @@ -197,9 +223,8 @@ def _print_coverage_table(entries: list[ScenarioStatusEntry]) -> None: "--format", "format_type", type=click.Choice(["table", "json", "badge"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table', 'json', or 'badge'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option( "--threshold", @@ -214,15 +239,20 @@ def _print_coverage_table(entries: list[ScenarioStatusEntry]) -> None: default=None, help="Output file for badge format.", ) +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") def coverage( features_dir: str | None, - format_type: str, + format_type: str | None, threshold: int | None, output_path: str | None, + pretty: bool, ) -> None: """Show high-level coverage metrics.""" from specleft.validator import load_specs_directory + selected_format = resolve_output_format( + format_type, choices=("table", "json", "badge") + ) resolved_features_dir = resolve_specs_dir(features_dir) try: config = load_specs_directory(resolved_features_dir) @@ -236,16 +266,19 @@ def coverage( sys.exit(1) # Gentle nudge for nested structures (table output only) - if format_type == "table": + if selected_format == "table": warn_if_nested_structure(resolved_features_dir) entries = build_status_entries(config, Path("tests")) metrics = _build_coverage_metrics(entries) - if format_type == "json": - payload = _build_coverage_json(entries) - click.echo(json.dumps(payload, indent=2)) - elif format_type == "badge": + if selected_format == "json": + if threshold is not None: + payload = _build_threshold_json(metrics, threshold=threshold) + else: + payload = _build_coverage_json(entries) + click.echo(json_dumps(payload, pretty=pretty)) + elif selected_format == "badge": if not output_path: click.secho("Badge format requires --output.", fg="red", err=True) print_support_footer() diff --git a/src/specleft/commands/doctor.py b/src/specleft/commands/doctor.py index 742ccb0..eb4064d 100644 --- a/src/specleft/commands/doctor.py +++ b/src/specleft/commands/doctor.py @@ -5,7 +5,6 @@ from __future__ import annotations -import json import os import re import subprocess @@ -17,6 +16,7 @@ import click from specleft.commands.constants import CLI_VERSION +from specleft.commands.output import json_dumps, resolve_output_format from specleft.utils.messaging import print_support_footer from specleft.utils.skill_integrity import ( INTEGRITY_MODIFIED, @@ -170,14 +170,20 @@ def _build_doctor_checks(*, verify_skill: bool) -> dict[str, Any]: def _build_doctor_output(checks: dict[str, Any]) -> dict[str, Any]: checks_map = cast(dict[str, Any], checks.get("checks", {})) errors: list[str] = [] + error_details: list[dict[str, str]] = [] suggestions: list[str] = [] healthy = True python_check = checks_map.get("python_version", {}) if python_check.get("status") == "fail": healthy = False - errors.append( - f"Python version {python_check.get('version')} is below minimum {python_check.get('minimum')}" + message = f"Python version {python_check.get('version')} is below minimum {python_check.get('minimum')}" + errors.append(message) + error_details.append( + { + "message": message, + "fix_command": "pyenv install 3.11 && pyenv local 3.11", + } ) suggestions.append("Upgrade Python: pyenv install 3.11") @@ -187,17 +193,25 @@ def _build_doctor_output(checks: dict[str, Any]) -> dict[str, Any]: for package in dependencies_check.get("packages", []): if package.get("status") == "fail": name = package.get("name") - errors.append(f"Missing required package: {name}") + message = f"Missing required package: {name}" + errors.append(message) + error_details.append( + {"message": message, "fix_command": f"pip install {name}"} + ) suggestions.append(f"Install {name}: pip install {name}") if checks_map.get("pytest_plugin", {}).get("status") == "fail": healthy = False - errors.append("Pytest plugin registration check failed") + message = "Pytest plugin registration check failed" + errors.append(message) + error_details.append({"message": message, "fix_command": "pip install -e ."}) suggestions.append("Ensure SpecLeft is installed: pip install -e .") if checks_map.get("directories", {}).get("status") != "pass": healthy = False - errors.append("Feature/test directory access issue") + message = "Feature/test directory access issue" + errors.append(message) + error_details.append({"message": message}) suggestions.append("Check directory permissions") skill_check = checks_map.get("skill_file_integrity") @@ -205,7 +219,11 @@ def _build_doctor_output(checks: dict[str, Any]) -> dict[str, Any]: integrity = skill_check.get("integrity") if integrity == INTEGRITY_MODIFIED: healthy = False - errors.append("Skill file integrity verification failed") + message = "Skill file integrity verification failed" + errors.append(message) + error_details.append( + {"message": message, "fix_command": "specleft skill update"} + ) suggestions.append("Run: specleft skill update") elif integrity == INTEGRITY_OUTDATED: suggestions.append("Skill file is outdated. Run: specleft skill update") @@ -219,6 +237,7 @@ def _build_doctor_output(checks: dict[str, Any]) -> dict[str, Any]: if errors: output["errors"] = errors + output["error_details"] = error_details if suggestions: output["suggestions"] = suggestions @@ -298,9 +317,8 @@ def _print_doctor_table(checks: dict[str, Any], *, verbose: bool) -> None: "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option("--verbose", is_flag=True, help="Show detailed diagnostic information.") @click.option( @@ -308,13 +326,17 @@ def _print_doctor_table(checks: dict[str, Any], *, verbose: bool) -> None: is_flag=True, help="Verify .specleft/SKILL.md checksum and template freshness.", ) -def doctor(format_type: str, verbose: bool, verify_skill: bool) -> None: +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") +def doctor( + format_type: str | None, verbose: bool, verify_skill: bool, pretty: bool +) -> None: """Verify SpecLeft installation and environment.""" + selected_format = resolve_output_format(format_type) checks = _build_doctor_checks(verify_skill=verify_skill) output = _build_doctor_output(checks) - if format_type == "json": - click.echo(json.dumps(output, indent=2)) + if selected_format == "json": + click.echo(json_dumps(output, pretty=pretty)) else: _print_doctor_table(output, verbose=verbose) if not output.get("healthy"): diff --git a/src/specleft/commands/enforce.py b/src/specleft/commands/enforce.py index d5dd9b7..863a616 100644 --- a/src/specleft/commands/enforce.py +++ b/src/specleft/commands/enforce.py @@ -5,7 +5,6 @@ from __future__ import annotations -import json import sys from datetime import date from pathlib import Path @@ -13,6 +12,7 @@ import click import yaml +from specleft.commands.output import json_dumps, resolve_output_format from specleft.specleft_signing.schema import PolicyType, SignedPolicy from specleft.specleft_signing.verify import VerifyFailure, VerifyResult, verify_policy @@ -190,15 +190,41 @@ def display_violations(violations: dict[str, Any]) -> None: ) +def _augment_violations_with_fix_commands( + violations: dict[str, Any], +) -> dict[str, Any]: + payload = dict(violations) + priority_violations: list[dict[str, Any]] = [] + for violation in violations.get("priority_violations", []): + entry = dict(violation) + feature_id = str(entry.get("feature_id", "")) + priority = str(entry.get("priority", "")).lower() + if feature_id and priority: + entry["fix_command"] = ( + f"specleft next --feature {feature_id} --priority {priority} --limit 1" + ) + priority_violations.append(entry) + payload["priority_violations"] = priority_violations + + coverage_violations: list[dict[str, Any]] = [] + for violation in violations.get("coverage_violations", []): + entry = dict(violation) + threshold = entry.get("threshold") + if threshold is not None: + entry["fix_command"] = f"specleft coverage --threshold {threshold}" + coverage_violations.append(entry) + payload["coverage_violations"] = coverage_violations + return payload + + @click.command("enforce") @click.argument("policy_file", type=click.Path(exists=False), default=None) @click.option( "--format", "fmt", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option( "--ignore-feature-id", @@ -218,12 +244,14 @@ def display_violations(violations: dict[str, Any]) -> None: default="tests", help="Path to tests directory.", ) +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") def enforce( policy_file: str | None, - fmt: str, + fmt: str | None, ignored: tuple[str, ...], features_dir: str | None, test_dir: str, + pretty: bool, ) -> None: """Enforce policy against the source code. @@ -237,6 +265,8 @@ def enforce( """ from specleft.validator import load_specs_directory + selected_format = resolve_output_format(fmt) + # Load policy policy_path = resolve_policy_path(policy_file) policy = load_policy(str(policy_path)) @@ -306,7 +336,7 @@ def enforce( sys.exit(2) # Show policy status (table format only) - if fmt == "table": + if selected_format == "table": display_policy_status(policy) click.echo("Checking scenarios...") if policy.policy_type == PolicyType.ENFORCE: @@ -321,8 +351,13 @@ def enforce( tests_dir=test_dir, ) - if fmt == "json": - click.echo(json.dumps(violations, indent=2)) + if selected_format == "json": + click.echo( + json_dumps( + _augment_violations_with_fix_commands(violations), + pretty=pretty, + ) + ) else: display_violations(violations) diff --git a/src/specleft/commands/features.py b/src/specleft/commands/features.py index 52162f4..96b286a 100644 --- a/src/specleft/commands/features.py +++ b/src/specleft/commands/features.py @@ -5,7 +5,6 @@ from __future__ import annotations -import json import re import sys from datetime import datetime @@ -14,6 +13,7 @@ import click +from specleft.commands.output import json_dumps, resolve_output_format from specleft.commands.test import generate_test_stub from specleft.schema import ( FeatureSpec, @@ -295,9 +295,26 @@ def _print_feature_add_result( result: dict[str, object], format_type: str, dry_run: bool, + pretty: bool, ) -> None: if format_type == "json": - click.echo(json.dumps(result, indent=2)) + if result.get("success") is False: + click.echo(json_dumps(result, pretty=pretty)) + return + + if dry_run: + payload = { + "dry_run": True, + "feature_id": result.get("feature_id"), + "file": result.get("file_path"), + } + else: + payload = { + "created": True, + "feature_id": result.get("feature_id"), + "file": result.get("file_path"), + } + click.echo(json_dumps(payload, pretty=pretty)) return if result.get("success") is False: @@ -323,9 +340,32 @@ def _print_scenario_add_result( format_type: str, dry_run: bool, warnings: list[str], + pretty: bool, ) -> None: if format_type == "json": - click.echo(json.dumps(result, indent=2)) + if result.get("success") is False: + click.echo(json_dumps(result, pretty=pretty)) + return + + if dry_run: + payload = { + "dry_run": True, + "feature_id": result.get("feature_id"), + "scenario_id": result.get("scenario_id"), + "file": result.get("file_path"), + } + else: + payload = { + "created": True, + "feature_id": result.get("feature_id"), + "scenario_id": result.get("scenario_id"), + "file": result.get("file_path"), + } + if result.get("test_preview"): + payload["test_preview"] = result.get("test_preview") + if warnings: + payload["warnings"] = warnings + click.echo(json_dumps(payload, pretty=pretty)) return if result.get("success") is False: @@ -369,19 +409,22 @@ def features() -> None: "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option( "--strict", is_flag=True, help="Treat warnings as errors.", ) -def features_validate(features_dir: str | None, format_type: str, strict: bool) -> None: +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") +def features_validate( + features_dir: str | None, format_type: str | None, strict: bool, pretty: bool +) -> None: """Validate Markdown specs in a features directory.""" from specleft.validator import collect_spec_stats, load_specs_directory + selected_format = resolve_output_format(format_type) warnings: list[dict[str, object]] = [] resolved_features_dir = resolve_specs_dir(features_dir) try: @@ -389,20 +432,11 @@ def features_validate(features_dir: str | None, format_type: str, strict: bool) stats = collect_spec_stats(config) # Gentle nudge for nested structures (table output only) - if format_type != "json": + if selected_format != "json": warn_if_nested_structure(resolved_features_dir) - if format_type == "json": - payload = { - "valid": True, - "timestamp": datetime.now().isoformat(), - "features": stats.feature_count, - "stories": stats.story_count, - "scenarios": stats.scenario_count, - "errors": [], - "warnings": warnings, - } - click.echo(json.dumps(payload, indent=2)) + if selected_format == "json": + click.echo(json_dumps({"valid": True}, pretty=pretty)) else: click.secho( f"✅ Features directory '{resolved_features_dir}/' is valid", bold=True @@ -417,22 +451,18 @@ def features_validate(features_dir: str | None, format_type: str, strict: bool) sys.exit(2) sys.exit(0) except FileNotFoundError: - if format_type == "json": + if selected_format == "json": payload = { "valid": False, - "timestamp": datetime.now().isoformat(), - "features": 0, - "stories": 0, - "scenarios": 0, "errors": [ { "file": str(resolved_features_dir), "message": f"Directory not found: {resolved_features_dir}", + "fix_command": f"mkdir -p {resolved_features_dir}", } ], - "warnings": warnings, } - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) else: click.secho( f"✗ Directory not found: {resolved_features_dir}", @@ -442,41 +472,31 @@ def features_validate(features_dir: str | None, format_type: str, strict: bool) print_support_footer() sys.exit(1) except ValueError as e: - if format_type == "json": + if selected_format == "json": payload = { "valid": False, - "timestamp": datetime.now().isoformat(), - "features": 0, - "stories": 0, - "scenarios": 0, "errors": [ { "message": str(e), } ], - "warnings": warnings, } - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) else: click.secho(f"✗ Validation failed: {e}", fg="red", err=True) print_support_footer() sys.exit(1) except Exception as e: - if format_type == "json": + if selected_format == "json": payload = { "valid": False, - "timestamp": datetime.now().isoformat(), - "features": 0, - "stories": 0, - "scenarios": 0, "errors": [ { "message": f"Unexpected validation failure: {e}", } ], - "warnings": warnings, } - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) else: click.secho(f"✗ Unexpected validation failure: {e}", fg="red", err=True) print_support_footer() @@ -494,24 +514,27 @@ def features_validate(features_dir: str | None, format_type: str, strict: bool) "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) -def features_list(features_dir: str | None, format_type: str) -> None: +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") +def features_list( + features_dir: str | None, format_type: str | None, pretty: bool +) -> None: """List features, stories, and scenarios.""" from specleft.validator import load_specs_directory + selected_format = resolve_output_format(format_type) resolved_features_dir = resolve_specs_dir(features_dir) try: config = load_specs_directory(resolved_features_dir) except FileNotFoundError: - if format_type == "json": + if selected_format == "json": error_payload = { "status": "error", "message": f"Directory not found: {resolved_features_dir}", } - click.echo(json.dumps(error_payload, indent=2)) + click.echo(json_dumps(error_payload, pretty=pretty)) else: click.secho( f"✗ Directory not found: {resolved_features_dir}", @@ -521,31 +544,31 @@ def features_list(features_dir: str | None, format_type: str) -> None: print_support_footer() sys.exit(1) except ValueError as e: - if format_type == "json": + if selected_format == "json": error_payload = { "status": "error", "message": f"Unable to load specs: {e}", } - click.echo(json.dumps(error_payload, indent=2)) + click.echo(json_dumps(error_payload, pretty=pretty)) else: click.secho(f"✗ Unable to load specs: {e}", fg="red", err=True) print_support_footer() sys.exit(1) except Exception as e: - if format_type == "json": + if selected_format == "json": error_payload = { "status": "error", "message": f"Unexpected error loading specs: {e}", } - click.echo(json.dumps(error_payload, indent=2)) + click.echo(json_dumps(error_payload, pretty=pretty)) else: click.secho(f"✗ Unexpected error loading specs: {e}", fg="red", err=True) print_support_footer() sys.exit(1) - if format_type == "json": + if selected_format == "json": payload = _build_features_list_json(config) - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) return # Gentle nudge for nested structures @@ -577,14 +600,20 @@ def features_list(features_dir: str | None, format_type: str) -> None: "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) -def features_stats(features_dir: str | None, tests_dir: str, format_type: str) -> None: +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") +def features_stats( + features_dir: str | None, + tests_dir: str, + format_type: str | None, + pretty: bool, +) -> None: """Show aggregate statistics for specs and test coverage.""" from specleft.validator import collect_spec_stats, load_specs_directory + selected_format = resolve_output_format(format_type) config = None stats = None spec_scenario_ids: set[str] = set() @@ -598,12 +627,12 @@ def features_stats(features_dir: str | None, tests_dir: str, format_type: str) - for scenario in story.scenarios: spec_scenario_ids.add(scenario.scenario_id) except FileNotFoundError: - if format_type == "json": + if selected_format == "json": error_payload = { "status": "error", "message": f"Directory not found: {resolved_features_dir}", } - click.echo(json.dumps(error_payload, indent=2)) + click.echo(json_dumps(error_payload, pretty=pretty)) else: click.secho( f"✗ Directory not found: {resolved_features_dir}", @@ -614,41 +643,41 @@ def features_stats(features_dir: str | None, tests_dir: str, format_type: str) - sys.exit(1) except ValueError as e: if "No feature specs found" in str(e): - if format_type == "json": + if selected_format == "json": stats = None else: click.secho(f"No specs found in {resolved_features_dir}.", fg="yellow") stats = None else: - if format_type == "json": + if selected_format == "json": error_payload = { "status": "error", "message": f"Unable to load specs: {e}", } - click.echo(json.dumps(error_payload, indent=2)) + click.echo(json_dumps(error_payload, pretty=pretty)) else: click.secho(f"✗ Unable to load specs: {e}", fg="red", err=True) print_support_footer() sys.exit(1) except Exception as e: - if format_type == "json": + if selected_format == "json": error_payload = { "status": "error", "message": f"Unexpected error loading specs: {e}", } - click.echo(json.dumps(error_payload, indent=2)) + click.echo(json_dumps(error_payload, pretty=pretty)) else: click.secho(f"✗ Unexpected error loading specs: {e}", fg="red", err=True) print_support_footer() sys.exit(1) # Gentle nudge for nested structures (table output only) - if format_type == "table": + if selected_format == "table": warn_if_nested_structure(resolved_features_dir) test_discovery = discover_pytest_tests(tests_dir) - if format_type == "json": + if selected_format == "json": payload = _build_features_stats_json( features_dir=resolved_features_dir, tests_dir=tests_dir, @@ -656,7 +685,7 @@ def features_stats(features_dir: str | None, tests_dir: str, format_type: str) - spec_scenario_ids=spec_scenario_ids, test_discovery=test_discovery, ) - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) return click.echo("") @@ -748,11 +777,11 @@ def features_stats(features_dir: str | None, tests_dir: str, format_type: str) - "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option("--interactive", is_flag=True, help="Use guided prompts.") +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") def features_add( feature_id: str | None, title: str | None, @@ -760,10 +789,12 @@ def features_add( description: str | None, features_dir: str | None, dry_run: bool, - format_type: str, + format_type: str | None, interactive: bool, + pretty: bool, ) -> None: """Create a new feature markdown file.""" + selected_format = resolve_output_format(format_type) _ensure_interactive(interactive) if interactive: @@ -815,7 +846,10 @@ def features_add( "error": str(exc), } _print_feature_add_result( - result=payload, format_type=format_type, dry_run=dry_run + result=payload, + format_type=selected_format, + dry_run=dry_run, + pretty=pretty, ) sys.exit(1) @@ -847,7 +881,12 @@ def features_add( {"title": title, "priority": priority, "description": description}, ) - _print_feature_add_result(result=payload, format_type=format_type, dry_run=dry_run) + _print_feature_add_result( + result=payload, + format_type=selected_format, + dry_run=dry_run, + pretty=pretty, + ) if not result.success: sys.exit(1) @@ -892,9 +931,8 @@ def features_add( "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option("--interactive", is_flag=True, help="Use guided prompts.") @click.option( @@ -908,6 +946,7 @@ def features_add( is_flag=True, help="Print the generated test content.", ) +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") def features_add_scenario( feature_id: str | None, title: str | None, @@ -918,12 +957,14 @@ def features_add_scenario( features_dir: str | None, tests_dir: Path | None, dry_run: bool, - format_type: str, + format_type: str | None, interactive: bool, add_test: str | None, preview_test: bool, + pretty: bool, ) -> None: """Append a scenario to an existing feature file.""" + selected_format = resolve_output_format(format_type) _ensure_interactive(interactive) if interactive: @@ -976,9 +1017,10 @@ def features_add_scenario( } _print_scenario_add_result( result=payload, - format_type=format_type, + format_type=selected_format, dry_run=dry_run, warnings=[], + pretty=pretty, ) sys.exit(1) @@ -998,9 +1040,10 @@ def features_add_scenario( } _print_scenario_add_result( result=payload, - format_type=format_type, + format_type=selected_format, dry_run=dry_run, warnings=warnings, + pretty=pretty, ) sys.exit(1) @@ -1017,9 +1060,10 @@ def features_add_scenario( } _print_scenario_add_result( result=payload, - format_type=format_type, + format_type=selected_format, dry_run=dry_run, warnings=warnings, + pretty=pretty, ) sys.exit(1) @@ -1057,9 +1101,10 @@ def features_add_scenario( ) _print_scenario_add_result( result=payload, - format_type=format_type, + format_type=selected_format, dry_run=dry_run, warnings=warnings, + pretty=pretty, ) sys.exit(1) @@ -1117,7 +1162,7 @@ def features_add_scenario( ) if not created and error: click.secho(f"Warning: {error}", fg="yellow") - elif format_type == "table" and not dry_run and not preview_test: + elif selected_format == "table" and not dry_run and not preview_test: if click.confirm("Generate test skeleton?", default=True): default_tests_dir = tests_dir or Path("tests") try: @@ -1146,7 +1191,7 @@ def features_add_scenario( click.secho(f"Warning: {error}", fg="yellow") if preview_test and generated_test: - if format_type == "json": + if selected_format == "json": payload["test_preview"] = generated_test else: click.echo("\nTest preview:\n") @@ -1154,7 +1199,8 @@ def features_add_scenario( _print_scenario_add_result( result=payload, - format_type=format_type, + format_type=selected_format, dry_run=dry_run, warnings=warnings, + pretty=pretty, ) diff --git a/src/specleft/commands/guide.py b/src/specleft/commands/guide.py index b981da0..dfecf6d 100644 --- a/src/specleft/commands/guide.py +++ b/src/specleft/commands/guide.py @@ -5,8 +5,6 @@ from __future__ import annotations -import json - import click from specleft.commands.guide_content import ( @@ -16,6 +14,7 @@ TASK_MAPPINGS, ) from specleft.commands.guide_content import get_guide_json +from specleft.commands.output import json_dumps, resolve_output_format def _format_table() -> str: @@ -79,14 +78,15 @@ def _format_table() -> str: "--format", "output_format", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) -def guide(output_format: str) -> None: +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") +def guide(output_format: str | None, pretty: bool) -> None: """Display SpecLeft workflow guide.""" - if output_format == "json": - click.echo(json.dumps(get_guide_json(), indent=2)) + selected_format = resolve_output_format(output_format) + if selected_format == "json": + click.echo(json_dumps(get_guide_json(), pretty=pretty)) return click.echo(_format_table()) diff --git a/src/specleft/commands/init.py b/src/specleft/commands/init.py index 0788425..f62eb92 100644 --- a/src/specleft/commands/init.py +++ b/src/specleft/commands/init.py @@ -5,7 +5,6 @@ from __future__ import annotations -import json import sys import textwrap from pathlib import Path @@ -13,6 +12,7 @@ import click +from specleft.commands.output import json_dumps, resolve_output_format from specleft.utils.messaging import print_support_footer from specleft.utils.skill_integrity import ( SKILL_FILE_PATH, @@ -186,17 +186,20 @@ def _apply_init_plan( "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) -def init(example: bool, blank: bool, dry_run: bool, format_type: str) -> None: +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") +def init( + example: bool, blank: bool, dry_run: bool, format_type: str | None, pretty: bool +) -> None: """Initialize SpecLeft project directories and example specs.""" + selected_format = resolve_output_format(format_type) if example and blank: message = "Choose either --example or --blank, not both." - if format_type == "json": + if selected_format == "json": payload = {"status": "error", "message": message} - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) else: click.secho(message, fg="red", err=True) print_support_footer() @@ -210,12 +213,12 @@ def init(example: bool, blank: bool, dry_run: bool, format_type: str) -> None: features_dir = Path(".specleft/specs") if features_dir.exists(): - if format_type == "json": + if selected_format == "json": payload_cancelled = { "status": "cancelled", "message": "Initialization cancelled; existing features directory requires confirmation.", } - click.echo(json.dumps(payload_cancelled, indent=2)) + click.echo(json_dumps(payload_cancelled, pretty=pretty)) sys.exit(2) choice = _prompt_init_action(features_dir) if choice == "3": @@ -229,20 +232,15 @@ def init(example: bool, blank: bool, dry_run: bool, format_type: str) -> None: if dry_run: would_create = [str(path) for path, _ in files] would_create.extend([str(SKILL_FILE_PATH), str(SKILL_HASH_PATH)]) - if format_type == "json": + if selected_format == "json": payload_dry_run = { - "status": "ok", "dry_run": True, - "example": example, - "would_create": would_create, - "would_create_directories": [str(path) for path in directories], - "summary": { - "files": len(would_create), - "directories": len(directories), - }, + "files_planned": len(would_create), + "directories_planned": len(directories), + "files": would_create, "skill_file_hash": skill_template_hash(), } - click.echo(json.dumps(payload_dry_run, indent=2)) + click.echo(json_dumps(payload_dry_run, pretty=pretty)) return click.echo( "Creating SpecLeft example project..." @@ -255,22 +253,26 @@ def init(example: bool, blank: bool, dry_run: bool, format_type: str) -> None: _print_license_notice() return - if format_type == "json" and not dry_run: - payload_error = { - "status": "error", - "message": "JSON output requires --dry-run to avoid interactive prompts.", - } - click.echo(json.dumps(payload_error, indent=2)) - sys.exit(1) - - click.echo( - "Creating SpecLeft example project..." - if example - else "Creating SpecLeft directory structure..." - ) - click.echo("") + if selected_format == "table": + click.echo( + "Creating SpecLeft example project..." + if example + else "Creating SpecLeft directory structure..." + ) + click.echo("") created = _apply_init_plan(directories, files) skill_sync = sync_skill_files(overwrite_existing=False) + + if selected_format == "json": + json_payload: dict[str, object] = { + "success": True, + "health": {"ok": True}, + "skill_file": str(SKILL_FILE_PATH), + "skill_file_hash": skill_sync.skill_file_hash, + } + click.echo(json_dumps(json_payload, pretty=pretty)) + return + for created_path in created: if created_path.is_dir(): click.echo(f"✓ Created {created_path}/") diff --git a/src/specleft/commands/next.py b/src/specleft/commands/next.py index b739746..5994e03 100644 --- a/src/specleft/commands/next.py +++ b/src/specleft/commands/next.py @@ -5,7 +5,6 @@ from __future__ import annotations -import json import sys from datetime import datetime from pathlib import Path @@ -14,6 +13,11 @@ import click from specleft.commands.formatters import get_priority_value +from specleft.commands.output import ( + compact_mode_enabled, + json_dumps, + resolve_output_format, +) from specleft.commands.status import build_status_entries from specleft.commands.types import ScenarioStatusEntry, StatusSummary from specleft.utils.messaging import print_support_footer @@ -123,18 +127,16 @@ def _build_next_json( ) @click.option( "--limit", - default=5, - show_default=True, + default=None, type=int, - help="Number of tests to show.", + help="Number of tests to show (default: 5, or 1 when SPECLEFT_COMPACT=1).", ) @click.option( "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option( "--priority", @@ -144,17 +146,24 @@ def _build_next_json( ) @click.option("--feature", "feature_id", help="Filter by feature ID.") @click.option("--story", "story_id", help="Filter by story ID.") +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") def next_command( features_dir: str | None, - limit: int, - format_type: str, + limit: int | None, + format_type: str | None, priority_filter: str | None, feature_id: str | None, story_id: str | None, + pretty: bool, ) -> None: """Show the next tests to implement.""" from specleft.validator import load_specs_directory + selected_format = resolve_output_format(format_type) + effective_limit = limit + if effective_limit is None: + effective_limit = 1 if compact_mode_enabled() else 5 + resolved_features_dir = resolve_specs_dir(features_dir) try: config = load_specs_directory(resolved_features_dir) @@ -168,7 +177,7 @@ def next_command( sys.exit(1) # Gentle nudge for nested structures (table output only) - if format_type == "table": + if selected_format == "table": warn_if_nested_structure(resolved_features_dir) entries = build_status_entries( @@ -196,9 +205,9 @@ def next_command( ) ) - limited = unimplemented[: max(limit, 0)] - if format_type == "json": + limited = unimplemented[: max(effective_limit, 0)] + if selected_format == "json": payload = _build_next_json(limited, len(unimplemented)) - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) else: _print_next_table(limited, summary) diff --git a/src/specleft/commands/output.py b/src/specleft/commands/output.py new file mode 100644 index 0000000..5cd5822 --- /dev/null +++ b/src/specleft/commands/output.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 SpecLeft Contributors + +"""Shared CLI output helpers.""" + +from __future__ import annotations + +import json +import os +import sys +from collections.abc import Sequence +from typing import Any + +COMPACT_ENV_VAR = "SPECLEFT_COMPACT" + + +def compact_mode_enabled() -> bool: + """Return True when compact output mode is enabled.""" + raw_value = os.getenv(COMPACT_ENV_VAR) + if raw_value is None: + return False + + normalized = raw_value.strip().lower() + return normalized not in {"", "0", "false", "no", "off"} + + +def resolve_output_format( + selected_format: str | None, + *, + choices: Sequence[str] = ("table", "json"), +) -> str: + """Resolve output format with TTY-aware defaults when no explicit format is set.""" + available = tuple(choice.lower() for choice in choices) + if selected_format: + return selected_format.lower() + + if "table" in available and "json" in available: + return "table" if sys.stdout.isatty() else "json" + + if "json" in available: + return "json" + + if not available: + raise ValueError("At least one output format choice is required.") + return available[0] + + +def json_dumps(payload: Any, *, pretty: bool = False) -> str: + """Serialize JSON using compact separators by default.""" + if pretty: + return json.dumps(payload, indent=2, default=str) + return json.dumps(payload, separators=(",", ":"), default=str) diff --git a/src/specleft/commands/plan.py b/src/specleft/commands/plan.py index c5b0cc6..1876fb0 100644 --- a/src/specleft/commands/plan.py +++ b/src/specleft/commands/plan.py @@ -5,7 +5,6 @@ from __future__ import annotations -import json import re import textwrap from datetime import datetime @@ -15,6 +14,7 @@ import click from slugify import slugify +from specleft.commands.output import json_dumps, resolve_output_format from specleft.license.status import resolve_license from specleft.utils.specs_dir import resolve_specs_dir from specleft.templates.prd_template import ( @@ -583,9 +583,8 @@ def _print_analyze_summary(summary: dict[str, int]) -> None: "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option("--dry-run", is_flag=True, help="Preview without writing files.") @click.option("--analyze", is_flag=True, help="Analyze PRD without writing files.") @@ -595,14 +594,17 @@ def _print_analyze_summary(summary: dict[str, int]) -> None: type=click.Path(dir_okay=False, path_type=Path), help="Path to a PRD template YAML file.", ) +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") def plan( prd_path: str, - format_type: str, + format_type: str | None, dry_run: bool, analyze: bool, template_path: Path | None, + pretty: bool, ) -> None: """Generate feature specs from a PRD.""" + selected_format = resolve_output_format(format_type) prd_file = Path(prd_path) template = default_template() template_info: dict[str, str] | None = None @@ -622,12 +624,12 @@ def plan( "version": template.version, } - if template_path is not None and format_type != "json": + if template_path is not None and selected_format != "json": click.echo(f"Using template: {template_path}") click.echo("") prd_content, warnings = _read_prd(prd_file) if prd_content is None: - if format_type == "json": + if selected_format == "json": payload = _build_plan_payload( prd_path=prd_file, dry_run=dry_run, @@ -637,7 +639,7 @@ def plan( warnings=warnings, template_info=template_info, ) - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) return for warning in warnings: @@ -652,7 +654,7 @@ def plan( analysis = _analyze_prd(prd_content, template) summary = cast(dict[str, int], analysis["summary"]) suggestions = cast(list[str], analysis["suggestions"]) - if format_type == "json": + if selected_format == "json": payload = { "timestamp": datetime.now().isoformat(), "status": "warning" if warnings else "ok", @@ -662,7 +664,7 @@ def plan( } if template_info: payload["template"] = template_info - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) return for warning in warnings: @@ -701,7 +703,7 @@ def plan( ) feature_count = len(titles) - if format_type == "json": + if selected_format == "json": payload = _build_plan_payload( prd_path=prd_file, dry_run=dry_run, @@ -712,7 +714,7 @@ def plan( orphan_scenarios=orphan_scenarios, template_info=template_info, ) - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) return for warning in warnings: diff --git a/src/specleft/commands/skill.py b/src/specleft/commands/skill.py index 825cf6f..a8e6522 100644 --- a/src/specleft/commands/skill.py +++ b/src/specleft/commands/skill.py @@ -5,12 +5,12 @@ from __future__ import annotations -import json import sys from typing import cast import click +from specleft.commands.output import json_dumps, resolve_output_format from specleft.utils.skill_integrity import ( INTEGRITY_MODIFIED, INTEGRITY_OUTDATED, @@ -44,15 +44,16 @@ def _print_integrity_table(payload: dict[str, object]) -> None: "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) -def skill_verify(format_type: str) -> None: +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") +def skill_verify(format_type: str | None, pretty: bool) -> None: """Verify SKILL.md integrity and freshness.""" + selected_format = resolve_output_format(format_type) result = verify_skill_integrity().to_payload() - if format_type == "json": - click.echo(json.dumps(result, indent=2)) + if selected_format == "json": + click.echo(json_dumps(result, pretty=pretty)) else: _print_integrity_table(result) @@ -86,15 +87,16 @@ def _print_sync_table(payload: dict[str, object]) -> None: "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) -def skill_update(format_type: str) -> None: +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") +def skill_update(format_type: str | None, pretty: bool) -> None: """Regenerate SKILL.md and checksum from the current SpecLeft version.""" + selected_format = resolve_output_format(format_type) payload = sync_skill_files(overwrite_existing=True).to_payload() - if format_type == "json": - click.echo(json.dumps(payload, indent=2)) + if selected_format == "json": + click.echo(json_dumps(payload, pretty=pretty)) else: _print_sync_table(payload) sys.exit(0) diff --git a/src/specleft/commands/status.py b/src/specleft/commands/status.py index 8d80a85..38c59df 100644 --- a/src/specleft/commands/status.py +++ b/src/specleft/commands/status.py @@ -6,7 +6,6 @@ from __future__ import annotations import ast -import json import sys from datetime import datetime from pathlib import Path @@ -15,6 +14,7 @@ import click from specleft.commands.formatters import get_priority_value +from specleft.commands.output import json_dumps, resolve_output_format from specleft.commands.types import ScenarioStatus, ScenarioStatusEntry, StatusSummary from specleft.schema import SpecsConfig from specleft.utils.messaging import print_support_footer @@ -174,15 +174,40 @@ def build_status_json( entries: list[ScenarioStatusEntry], *, include_execution_time: bool, + verbose: bool, ) -> dict[str, Any]: from specleft.commands.formatters import build_feature_json summary = _summarize_status_entries(entries) + summary_payload = { + "features": summary.total_features, + "stories": summary.total_stories, + "scenarios": summary.total_scenarios, + "total_features": summary.total_features, + "total_stories": summary.total_stories, + "total_scenarios": summary.total_scenarios, + "implemented": summary.implemented, + "skipped": summary.skipped, + "coverage_percent": summary.coverage_percent, + } + if not verbose: + return summary_payload + features: list[dict[str, Any]] = [] + by_priority: dict[str, dict[str, int]] = {} feature_groups: dict[str, list[ScenarioStatusEntry]] = {} for entry in entries: feature_groups.setdefault(entry.feature.feature_id, []).append(entry) + priority = get_priority_value(entry.scenario) + priority_payload = by_priority.setdefault( + priority, {"total": 0, "implemented": 0, "skipped": 0} + ) + priority_payload["total"] += 1 + if entry.status == "implemented": + priority_payload["implemented"] += 1 + else: + priority_payload["skipped"] += 1 for feature_entries in feature_groups.values(): feature_summary = _summarize_status_entries(feature_entries) @@ -230,16 +255,11 @@ def build_status_json( features.append(feature_payload) return { + "initialised": True, "timestamp": datetime.now().isoformat(), + "summary": summary_payload, + "by_priority": by_priority, "features": features, - "summary": { - "total_features": summary.total_features, - "total_stories": summary.total_stories, - "total_scenarios": summary.total_scenarios, - "implemented": summary.implemented, - "skipped": summary.skipped, - "coverage_percent": summary.coverage_percent, - }, } @@ -362,9 +382,8 @@ def _is_single_file_feature(feature: object) -> bool: "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option("--feature", "feature_id", help="Filter by feature ID.") @click.option("--story", "story_id", help="Filter by story ID.") @@ -372,17 +391,27 @@ def _is_single_file_feature(feature: object) -> bool: "--unimplemented", is_flag=True, help="Show only unimplemented scenarios." ) @click.option("--implemented", is_flag=True, help="Show only implemented scenarios.") +@click.option( + "--verbose", + is_flag=True, + help="Return full status payload (default json output is summary-only).", +) +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") def status( features_dir: str | None, - format_type: str, + format_type: str | None, feature_id: str | None, story_id: str | None, unimplemented: bool, implemented: bool, + verbose: bool, + pretty: bool, ) -> None: """Show which scenarios are implemented vs. skipped.""" from specleft.validator import load_specs_directory + selected_format = resolve_output_format(format_type) + if unimplemented and implemented: click.secho( "Cannot use --implemented and --unimplemented together.", fg="red", err=True @@ -403,7 +432,7 @@ def status( sys.exit(1) # Gentle nudge for nested structures (table output only) - if format_type == "table": + if selected_format == "table": warn_if_nested_structure(resolved_features_dir) if feature_id and not any( @@ -437,9 +466,11 @@ def status( elif implemented: entries = [entry for entry in entries if entry.status == "implemented"] - if format_type == "json": - payload = build_status_json(entries, include_execution_time=True) - click.echo(json.dumps(payload, indent=2)) + if selected_format == "json": + payload = build_status_json( + entries, include_execution_time=True, verbose=verbose + ) + click.echo(json_dumps(payload, pretty=pretty)) else: show_only = None if unimplemented: diff --git a/src/specleft/commands/test.py b/src/specleft/commands/test.py index 99fd585..8c9a08e 100644 --- a/src/specleft/commands/test.py +++ b/src/specleft/commands/test.py @@ -8,12 +8,12 @@ import json import sys import webbrowser -from datetime import datetime from pathlib import Path import click from jinja2 import Environment, FileSystemLoader, Template +from specleft.commands.output import json_dumps, resolve_output_format from specleft.commands.formatters import get_priority_value from specleft.commands.types import ( ScenarioPlan, @@ -326,48 +326,21 @@ def _flatten_skeleton_entries( return entries -def _build_skeleton_json( +def _build_skeleton_compact_payload( *, - would_create: list[SkeletonScenarioEntry], - would_skip: list[SkeletonScenarioEntry], + plan_result: SkeletonPlanResult, dry_run: bool, - template: Template, ) -> dict[str, object]: - def _entry_payload(entry: SkeletonScenarioEntry) -> dict[str, object]: - preview_lines = _render_skeleton_preview_content( - template=template, - scenarios=[entry.scenario], - ).splitlines() - preview = "\n".join(preview_lines[:6]) - scenario = entry.scenario.scenario + files = sorted({str(plan.output_path) for plan in plan_result.plans}) + if dry_run: return { - "feature_id": entry.scenario.feature_id, - "story_id": entry.scenario.story_id, - "scenario_id": scenario.scenario_id, - "test_file": str(entry.output_path), - "test_function": scenario.test_function_name, - "steps": len(scenario.steps), - "priority": get_priority_value(scenario), - "preview": preview, - "overwrites": entry.overwrites, + "dry_run": True, + "files_planned": len(files), + "files": files, } - return { - "timestamp": datetime.now().isoformat(), - "dry_run": dry_run, - "would_create": [_entry_payload(entry) for entry in would_create], - "would_skip": [ - { - "scenario_id": entry.scenario.scenario.scenario_id, - "test_file": str(entry.output_path), - "reason": entry.skip_reason, - } - for entry in would_skip - ], - "summary": { - "would_create": len({entry.output_path for entry in would_create}), - "would_skip": len({entry.output_path for entry in would_skip}), - }, + "created": True, + "files_written": len(files), } @@ -462,26 +435,28 @@ def test() -> None: "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option( "--force", is_flag=True, help="Overwrite existing test files.", ) +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") def skeleton( features_dir: str | None, output_dir: str, single_file: bool, skip_preview: bool, dry_run: bool, - format_type: str, + format_type: str | None, force: bool, + pretty: bool, ) -> None: """Generate skeleton test files from Markdown feature specs.""" - if format_type == "json" and not dry_run and not force: + selected_format = resolve_output_format(format_type) + if selected_format == "json" and not dry_run and not force: click.secho( "JSON output requires --dry-run or --force to avoid prompts.", fg="red", @@ -521,7 +496,7 @@ def skeleton( sys.exit(1) # Gentle nudge for nested structures (table output only) - if format_type == "table": + if selected_format == "table": warn_if_nested_structure(resolved_features_dir) template = _load_test_template("skeleton_test.py.jinja2") @@ -539,15 +514,7 @@ def skeleton( would_create = [entry for entry in flattened if entry.skip_reason is None] would_skip = [entry for entry in flattened if entry.skip_reason is not None] - if format_type == "json": - payload = _build_skeleton_json( - would_create=would_create, - would_skip=would_skip, - dry_run=dry_run, - template=template, - ) - click.echo(json.dumps(payload, indent=2)) - else: + if selected_format == "table": _print_skeleton_plan_table( would_create=would_create, would_skip=would_skip, @@ -559,9 +526,27 @@ def skeleton( _render_skeleton_preview(plan) if dry_run: + if selected_format == "json": + click.echo( + json_dumps( + _build_skeleton_compact_payload( + plan_result=plan_result, + dry_run=True, + ), + pretty=pretty, + ) + ) return if not plan_result.plans: + if selected_format == "json": + click.echo( + json_dumps( + {"created": True, "files_written": 0}, + pretty=pretty, + ) + ) + return click.secho("No new skeleton tests to generate.", fg="magenta") return @@ -573,7 +558,19 @@ def skeleton( plan.output_path.parent.mkdir(parents=True, exist_ok=True) plan.output_path.write_text(plan.content) - if format_type == "table": + if selected_format == "json": + click.echo( + json_dumps( + _build_skeleton_compact_payload( + plan_result=plan_result, + dry_run=False, + ), + pretty=pretty, + ) + ) + return + + if selected_format == "table": click.secho(f"\n✓ Created {len(plan_result.plans)} test files", fg="green") for plan in plan_result.plans: click.echo(f"{plan.output_path}") @@ -615,26 +612,28 @@ def skeleton( "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) @click.option( "--force", is_flag=True, help="Overwrite existing test files.", ) +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") def stub( features_dir: str | None, output_dir: str, single_file: bool, skip_preview: bool, dry_run: bool, - format_type: str, + format_type: str | None, force: bool, + pretty: bool, ) -> None: """Generate stub test files from Markdown feature specs.""" - if format_type == "json" and not dry_run and not force: + selected_format = resolve_output_format(format_type) + if selected_format == "json" and not dry_run and not force: click.secho( "JSON output requires --dry-run or --force to avoid prompts.", fg="red", @@ -673,7 +672,7 @@ def stub( print_support_footer() sys.exit(1) - if format_type == "table": + if selected_format == "table": warn_if_nested_structure(resolved_features_dir) template = _load_test_template("stub_test.py.jinja2") @@ -691,15 +690,7 @@ def stub( would_create = [entry for entry in flattened if entry.skip_reason is None] would_skip = [entry for entry in flattened if entry.skip_reason is not None] - if format_type == "json": - payload = _build_skeleton_json( - would_create=would_create, - would_skip=would_skip, - dry_run=dry_run, - template=template, - ) - click.echo(json.dumps(payload, indent=2)) - else: + if selected_format == "table": _print_skeleton_plan_table( would_create=would_create, would_skip=would_skip, @@ -711,9 +702,27 @@ def stub( _render_skeleton_preview(plan) if dry_run: + if selected_format == "json": + click.echo( + json_dumps( + _build_skeleton_compact_payload( + plan_result=plan_result, + dry_run=True, + ), + pretty=pretty, + ) + ) return if not plan_result.plans: + if selected_format == "json": + click.echo( + json_dumps( + {"created": True, "files_written": 0}, + pretty=pretty, + ) + ) + return click.secho("No new stub tests to generate.", fg="magenta") return @@ -725,7 +734,19 @@ def stub( plan.output_path.parent.mkdir(parents=True, exist_ok=True) plan.output_path.write_text(plan.content) - if format_type == "table": + if selected_format == "json": + click.echo( + json_dumps( + _build_skeleton_compact_payload( + plan_result=plan_result, + dry_run=False, + ), + pretty=pretty, + ) + ) + return + + if selected_format == "table": click.secho(f"\n✓ Created {len(plan_result.plans)} test files", fg="green") for plan in plan_result.plans: click.echo(f"{plan.output_path}") @@ -756,25 +777,30 @@ def stub( "--format", "format_type", type=click.Choice(["table", "json"], case_sensitive=False), - default="table", - show_default=True, - help="Output format: 'table' or 'json'.", + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", ) +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") def report( - results_file: str | None, output: str, open_browser: bool, format_type: str + results_file: str | None, + output: str, + open_browser: bool, + format_type: str | None, + pretty: bool, ) -> None: """Generate HTML report from test results.""" + selected_format = resolve_output_format(format_type) results_dir = Path(".specleft/results") if results_file: results_path = Path(results_file) if not results_path.exists(): - if format_type == "json": + if selected_format == "json": payload = { "status": "error", "message": f"Results file not found: {results_file}", } - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) else: click.secho( f"Error: Results file not found: {results_file}", fg="red", err=True @@ -783,12 +809,12 @@ def report( sys.exit(1) else: if not results_dir.exists(): - if format_type == "json": + if selected_format == "json": payload = { "status": "error", "message": "No results found. Run tests first with pytest.", } - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) else: click.secho( "No results found. Run tests first with pytest.", @@ -800,44 +826,44 @@ def report( json_files = sorted(results_dir.glob("results_*.json")) if not json_files: - if format_type == "json": + if selected_format == "json": payload = { "status": "error", "message": "No results files found.", } - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) else: click.secho("No results files found.", fg="yellow", err=True) print_support_footer() sys.exit(1) results_path = json_files[-1] - if format_type == "table": + if selected_format == "table": click.echo(f"Using latest results: {results_path}") try: with results_path.open() as f: results = json.load(f) except json.JSONDecodeError as e: - if format_type == "json": + if selected_format == "json": payload = { "status": "error", "message": f"Invalid JSON in results file: {e}", } - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) else: click.secho(f"Invalid JSON in results file: {e}", fg="red", err=True) print_support_footer() sys.exit(1) - if format_type == "json": + if selected_format == "json": payload = { "status": "ok", "results_file": str(results_path), "summary": results.get("summary"), "features": results.get("features"), } - click.echo(json.dumps(payload, indent=2)) + click.echo(json_dumps(payload, pretty=pretty)) return templates_dir = Path(__file__).parent.parent / "templates" diff --git a/src/specleft/templates/skill_template.py b/src/specleft/templates/skill_template.py index 85c4248..c2ba9e7 100644 --- a/src/specleft/templates/skill_template.py +++ b/src/specleft/templates/skill_template.py @@ -13,14 +13,23 @@ def get_skill_content() -> str: return textwrap.dedent(""" # SpecLeft CLI Reference + ## Setup + `export SPECLEFT_COMPACT=1` + All commands below run in compact mode. + ## Workflow - 1. specleft next --format json + 1. specleft next --limit 1 2. Implement test logic - 3. specleft features validate --format json - 4. specleft skill verify --format json + 3. specleft features validate + 4. specleft skill verify 5. pytest 6. Repeat + ## Quick checks + - Validation: check exit code first, parse JSON only on failure + - Coverage: `specleft coverage --threshold 100` and check exit code + - Status: `specleft status` for progress snapshots + ## Safety - Always `--dry-run` before writing files - Never use `--force` unless explicitly requested diff --git a/tests/acceptance/test_feature-1-planning-mode.py b/tests/acceptance/test_feature-1-planning-mode.py index 24cad3a..8b4943d 100644 --- a/tests/acceptance/test_feature-1-planning-mode.py +++ b/tests/acceptance/test_feature-1-planning-mode.py @@ -334,7 +334,7 @@ def test_analyze_prd_structure_without_writing_files() -> None: assert Path("prd.md").exists() with specleft.step("When specleft plan --analyze is executed"): - result = runner.invoke(cli, ["plan", "--analyze"]) + result = runner.invoke(cli, ["plan", "--analyze", "--format", "table"]) with specleft.step( "Then the output classifies headings as feature, excluded, or ambiguous" diff --git a/tests/acceptance/test_feature-2-specification-format.py b/tests/acceptance/test_feature-2-specification-format.py index d0c0145..2aae01b 100644 --- a/tests/acceptance/test_feature-2-specification-format.py +++ b/tests/acceptance/test_feature-2-specification-format.py @@ -56,7 +56,6 @@ def test_minimal_valid_feature_file( assert result.exit_code == 0, f"Validation failed: {result.output}" payload = json.loads(result.output) assert payload["valid"] is True, f"Expected valid=True, got: {payload}" - assert payload["scenarios"] >= 1, "Expected at least 1 scenario" with specleft.step("And missing metadata fields are treated as null"): # List features with JSON to inspect metadata fields @@ -196,9 +195,3 @@ def test_optional_metadata_does_not_block_usage( # Overall validation should have passed for both features validate_payload = json.loads(validate_result.output) assert validate_payload["valid"] is True, "Both features should be valid" - assert ( - validate_payload["features"] == 2 - ), f"Expected 2 features, got: {validate_payload['features']}" - assert ( - len(validate_payload.get("errors", [])) == 0 - ), f"Expected no errors, got: {validate_payload.get('errors')}" diff --git a/tests/acceptance/test_feature-3-canonical-json-output.py b/tests/acceptance/test_feature-3-canonical-json-output.py index a62d9cb..b64d758 100644 --- a/tests/acceptance/test_feature-3-canonical-json-output.py +++ b/tests/acceptance/test_feature-3-canonical-json-output.py @@ -53,7 +53,7 @@ def test_emit_canonical_json_shape( with specleft.step("When output is produced"): # Use status command with --format json to get canonical output - result = runner.invoke(cli, ["status", "--format", "json"]) + result = runner.invoke(cli, ["status", "--format", "json", "--verbose"]) assert result.exit_code == 0, f"Command failed: {result.output}" payload = json.loads(result.output) @@ -125,7 +125,7 @@ def test_scenario_ids_are_deterministic( with specleft.step("When JSON is emitted"): # Run status command twice to verify determinism - result1 = runner.invoke(cli, ["status", "--format", "json"]) + result1 = runner.invoke(cli, ["status", "--format", "json", "--verbose"]) assert result1.exit_code == 0, f"First run failed: {result1.output}" payload1 = json.loads(result1.output) @@ -167,7 +167,7 @@ def test_scenario_ids_are_deterministic( with specleft.step("And repeated runs produce identical IDs"): # Run the same command again - result2 = runner.invoke(cli, ["status", "--format", "json"]) + result2 = runner.invoke(cli, ["status", "--format", "json", "--verbose"]) assert result2.exit_code == 0, f"Second run failed: {result2.output}" payload2 = json.loads(result2.output) diff --git a/tests/acceptance/test_feature-4-status-coverage-inspection.py b/tests/acceptance/test_feature-4-status-coverage-inspection.py index 4822248..a980a49 100644 --- a/tests/acceptance/test_feature-4-status-coverage-inspection.py +++ b/tests/acceptance/test_feature-4-status-coverage-inspection.py @@ -55,7 +55,9 @@ def test_report_unimplemented_scenarios( with specleft.step( "When specleft status --unimplemented --format json is executed" ): - result = runner.invoke(cli, ["status", "--unimplemented", "--format", "json"]) + result = runner.invoke( + cli, ["status", "--unimplemented", "--format", "json", "--verbose"] + ) assert result.exit_code == 0, f"Command failed: {result.output}" payload = json.loads(result.output) @@ -121,7 +123,9 @@ def test_report_implemented_scenarios( pass with specleft.step("When specleft status --implemented --format json is executed"): - result = runner.invoke(cli, ["status", "--implemented", "--format", "json"]) + result = runner.invoke( + cli, ["status", "--implemented", "--format", "json", "--verbose"] + ) assert result.exit_code == 0, f"Command failed: {result.output}" payload = json.loads(result.output) @@ -187,7 +191,8 @@ def test_status_of_implementation_by_feature( "When command is run spec status --feature feature-billing --format json" ): result = runner.invoke( - cli, ["status", "--feature", "feature-billing", "--format", "json"] + cli, + ["status", "--feature", "feature-billing", "--format", "json", "--verbose"], ) assert result.exit_code == 0, f"Command failed: {result.output}" payload = json.loads(result.output) diff --git a/tests/acceptance/test_feature-6-ci-experience-messaging.py b/tests/acceptance/test_feature-6-ci-experience-messaging.py index 9fa388e..2eb06d7 100644 --- a/tests/acceptance/test_feature-6-ci-experience-messaging.py +++ b/tests/acceptance/test_feature-6-ci-experience-messaging.py @@ -109,7 +109,7 @@ def test_ci_failure_explains_intent_mismatch( ): result = runner.invoke( cli, - ["enforce", ".specleft/policies/policy.yml"], + ["enforce", ".specleft/policies/policy.yml", "--format", "table"], ) output = result.output @@ -214,6 +214,8 @@ def test_documentation_and_support_links_on_ci_failure( [ "enforce", f".specleft/policies/{policy_filename}", + "--format", + "table", ], ) diff --git a/tests/acceptance/test_feature-7-autonomous-agent-test-execution.py b/tests/acceptance/test_feature-7-autonomous-agent-test-execution.py index 13c4f68..78d11c3 100644 --- a/tests/acceptance/test_feature-7-autonomous-agent-test-execution.py +++ b/tests/acceptance/test_feature-7-autonomous-agent-test-execution.py @@ -157,19 +157,11 @@ def test_generate_test_skeleton_for_a_scenario( payload = json.loads(result.output) with specleft.step("Then a test stub is generated in to ./tmp directory"): - # Check JSON output indicates files would be created + # Compact success payload confirms write count. + assert payload.get("created") is True, f"Expected created=true. Got: {payload}" assert ( - "would_create" in payload - ), f"Expected 'would_create' in output. Got: {payload}" - assert ( - len(payload["would_create"]) >= 1 - ), f"Expected at least 1 skeleton to be created. Got: {payload}" - - # Verify output path is in tmp directory - first_skeleton = payload["would_create"][0] - assert ( - "tmp" in first_skeleton["test_file"] - ), f"Expected test file in tmp directory. Got: {first_skeleton['test_file']}" + payload.get("files_written", 0) >= 1 + ), f"Expected at least 1 skeleton file written. Got: {payload}" # Verify actual file was created (dry_run should be False by default) # Check for the generated test file @@ -260,7 +252,7 @@ def test_agent_implements_behaviour_to_satisfy_the_test( ): result = runner.invoke( cli, - ["status", "--implemented", "--format", "json"], + ["status", "--implemented", "--format", "json", "--verbose"], ) assert result.exit_code == 0, ( diff --git a/tests/acceptance/test_feature-9-cli-feature-authoring.py b/tests/acceptance/test_feature-9-cli-feature-authoring.py index 14911a7..414ea83 100644 --- a/tests/acceptance/test_feature-9-cli-feature-authoring.py +++ b/tests/acceptance/test_feature-9-cli-feature-authoring.py @@ -366,6 +366,8 @@ def test_preview_test_content_for_a_scenario( "--step", "Given a preview step", "--preview-test", + "--format", + "table", "--dir", "features", ], diff --git a/tests/commands/test_doctor.py b/tests/commands/test_doctor.py index 618d934..8ff6bb8 100644 --- a/tests/commands/test_doctor.py +++ b/tests/commands/test_doctor.py @@ -60,7 +60,7 @@ def test_doctor_json_includes_version(self) -> None: def test_doctor_table_output(self) -> None: runner = CliRunner() - result = runner.invoke(cli, ["doctor"]) + result = runner.invoke(cli, ["doctor", "--format", "table"]) assert result.exit_code in {0, 1} assert "Checking SpecLeft installation" in result.output assert "specleft CLI available" in result.output @@ -68,7 +68,7 @@ def test_doctor_table_output(self) -> None: def test_doctor_verbose_output(self) -> None: runner = CliRunner() - result = runner.invoke(cli, ["doctor", "--verbose"]) + result = runner.invoke(cli, ["doctor", "--verbose", "--format", "table"]) assert result.exit_code in {0, 1} assert "pytest plugin" in result.output assert "feature directory" in result.output diff --git a/tests/commands/test_enforce_integration.py b/tests/commands/test_enforce_integration.py index 80e607c..374ceb2 100644 --- a/tests/commands/test_enforce_integration.py +++ b/tests/commands/test_enforce_integration.py @@ -96,7 +96,8 @@ def test_login_success(): return_value=RepoIdentity(owner="test-owner", name="test-repo"), ): result = runner.invoke( - cli, ["enforce", ".specleft/policies/policy.yml"] + cli, + ["enforce", ".specleft/policies/policy.yml", "--format", "table"], ) assert result.exit_code == 0 @@ -128,7 +129,8 @@ def test_workflow_core_fail(self) -> None: return_value=RepoIdentity(owner="test-owner", name="test-repo"), ): result = runner.invoke( - cli, ["enforce", ".specleft/policies/policy.yml"] + cli, + ["enforce", ".specleft/policies/policy.yml", "--format", "table"], ) assert result.exit_code == 1 @@ -173,7 +175,8 @@ def test_login_success(): return_value=RepoIdentity(owner="test-owner", name="test-repo"), ): result = runner.invoke( - cli, ["enforce", ".specleft/policies/policy.yml"] + cli, + ["enforce", ".specleft/policies/policy.yml", "--format", "table"], ) assert result.exit_code == 0 @@ -214,7 +217,8 @@ def test_login_success(): return_value=RepoIdentity(owner="test-owner", name="test-repo"), ): result = runner.invoke( - cli, ["enforce", ".specleft/policies/policy.yml"] + cli, + ["enforce", ".specleft/policies/policy.yml", "--format", "table"], ) assert result.exit_code == 0 @@ -265,14 +269,21 @@ def test_login_success(): ): # Enforce policy should fail (expired evaluation) result = runner.invoke( - cli, ["enforce", ".specleft/policies/policy.yml"] + cli, + ["enforce", ".specleft/policies/policy.yml", "--format", "table"], ) assert result.exit_code == 2 assert "Evaluation" in result.output and "ended" in result.output # Core policy should work result = runner.invoke( - cli, ["enforce", ".specleft/policies/policy-core.yml"] + cli, + [ + "enforce", + ".specleft/policies/policy-core.yml", + "--format", + "table", + ], ) assert result.exit_code == 0 assert "Core Policy (downgraded from Enforce)" in result.output @@ -322,7 +333,8 @@ def test_login_success(): ): # Without ignore - should fail (legacy not implemented) result = runner.invoke( - cli, ["enforce", ".specleft/policies/policy.yml"] + cli, + ["enforce", ".specleft/policies/policy.yml", "--format", "table"], ) assert result.exit_code == 1 @@ -334,6 +346,8 @@ def test_login_success(): ".specleft/policies/policy.yml", "--ignore-feature-id", "legacy", + "--format", + "table", ], ) assert result.exit_code == 0 diff --git a/tests/commands/test_features.py b/tests/commands/test_features.py index 94b23ad..995d970 100644 --- a/tests/commands/test_features.py +++ b/tests/commands/test_features.py @@ -38,7 +38,7 @@ def test_validate_valid_dir(self) -> None: scenario_id="login-success", ) - result = runner.invoke(cli, ["features", "validate"]) + result = runner.invoke(cli, ["features", "validate", "--format", "table"]) assert result.exit_code == 0 assert "is valid" in result.output assert "Features: 1" in result.output @@ -62,13 +62,12 @@ def test_validate_json_output(self) -> None: assert result.exit_code == 0 payload = json.loads(result.output) assert payload["valid"] is True - assert payload["features"] == 1 def test_validate_missing_dir(self) -> None: """Test validate command when directory is missing.""" runner = CliRunner() with runner.isolated_filesystem(): - result = runner.invoke(cli, ["features", "validate"]) + result = runner.invoke(cli, ["features", "validate", "--format", "table"]) assert result.exit_code == 1 assert "Directory not found" in result.output @@ -78,7 +77,7 @@ def test_validate_invalid_dir(self) -> None: with runner.isolated_filesystem(): Path("features").mkdir() - result = runner.invoke(cli, ["features", "validate"]) + result = runner.invoke(cli, ["features", "validate", "--format", "table"]) assert result.exit_code == 1 assert "Validation failed" in result.output @@ -95,7 +94,15 @@ def test_validate_custom_dir(self) -> None: ) result = runner.invoke( - cli, ["features", "validate", "--dir", str(features_dir)] + cli, + [ + "features", + "validate", + "--dir", + str(features_dir), + "--format", + "table", + ], ) assert result.exit_code == 0 assert "is valid" in result.output @@ -115,7 +122,7 @@ def test_features_list_outputs_tree(self) -> None: scenario_id="login-success", ) - result = runner.invoke(cli, ["features", "list"]) + result = runner.invoke(cli, ["features", "list", "--format", "table"]) assert result.exit_code == 0 assert "Features (1):" in result.output assert "- auth: Auth Feature" in result.output @@ -157,7 +164,7 @@ def test_nested_structure_warning(self) -> None: scenario_id="legacy-charge", ) - result = runner.invoke(cli, ["features", "list"]) + result = runner.invoke(cli, ["features", "list", "--format", "table"]) assert result.exit_code == 0 assert "Detected nested feature structure" in result.output assert ".specleft/specs/legacy/_feature.md" not in result.output @@ -165,7 +172,7 @@ def test_nested_structure_warning(self) -> None: def test_features_list_missing_dir(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): - result = runner.invoke(cli, ["features", "list"]) + result = runner.invoke(cli, ["features", "list", "--format", "table"]) assert result.exit_code == 1 assert "Directory not found" in result.output @@ -184,7 +191,7 @@ def test_features_stats_outputs_summary(self) -> None: include_test_data=True, ) - result = runner.invoke(cli, ["features", "stats"]) + result = runner.invoke(cli, ["features", "stats", "--format", "table"]) assert result.exit_code == 0 # New format includes test coverage stats assert "Test Coverage Stats" in result.output @@ -253,7 +260,7 @@ def test_login_success(): pass """) - result = runner.invoke(cli, ["features", "stats"]) + result = runner.invoke(cli, ["features", "stats", "--format", "table"]) assert result.exit_code == 0 assert "Scenarios with tests: 1" in result.output assert "Scenarios without tests: 0" in result.output @@ -297,7 +304,7 @@ def test_login_success(): pass """) - result = runner.invoke(cli, ["features", "stats"]) + result = runner.invoke(cli, ["features", "stats", "--format", "table"]) assert result.exit_code == 0 assert "Scenarios: 2" in result.output assert "Scenarios with tests: 1" in result.output diff --git a/tests/commands/test_features_add.py b/tests/commands/test_features_add.py index ee10e79..e394921 100644 --- a/tests/commands/test_features_add.py +++ b/tests/commands/test_features_add.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path from click.testing import CliRunner @@ -91,8 +92,9 @@ def test_add_json_output(self) -> None: ], ) assert result.exit_code == 0 - assert '"success": true' in result.output - assert '"feature_id": "json-feature"' in result.output + payload = json.loads(result.output) + assert payload["created"] is True + assert payload["feature_id"] == "json-feature" class TestFeaturesAddScenarioCommand: @@ -200,6 +202,8 @@ def test_add_scenario_preview_test(self) -> None: "--step", "Given a preview", "--preview-test", + "--format", + "table", "--dir", ".specleft/specs", ], @@ -264,8 +268,9 @@ def test_add_scenario_missing_feature_json_error(self) -> None: ], ) assert result.exit_code == 1 - assert '"success": false' in result.output - assert '"suggestion"' in result.output + payload = json.loads(result.output) + assert payload["success"] is False + assert "suggestion" in payload def test_add_scenario_invalid_id(self) -> None: runner = CliRunner() @@ -441,6 +446,8 @@ def test_add_scenario_interactive_accepts_tests_dir_prompt(self) -> None: "Add scenario", "--step", "Given a scenario", + "--format", + "table", "--dir", ".specleft/specs", ], @@ -478,6 +485,8 @@ def test_add_scenario_interactive_accepts_default_tests_dir(self) -> None: "Add scenario", "--step", "Given a scenario", + "--format", + "table", "--dir", ".specleft/specs", ], diff --git a/tests/commands/test_guide.py b/tests/commands/test_guide.py index 3720850..101b37c 100644 --- a/tests/commands/test_guide.py +++ b/tests/commands/test_guide.py @@ -15,7 +15,7 @@ class TestGuideCommand: def test_guide_table_output(self) -> None: runner = CliRunner() - result = runner.invoke(cli, ["guide"]) + result = runner.invoke(cli, ["guide", "--format", "table"]) assert result.exit_code == 0 assert "SpecLeft Workflow Guide" in result.output diff --git a/tests/commands/test_init.py b/tests/commands/test_init.py index 2c14380..688da8c 100644 --- a/tests/commands/test_init.py +++ b/tests/commands/test_init.py @@ -36,26 +36,26 @@ def test_init_json_dry_run(self) -> None: assert result.exit_code == 0 payload = json.loads(result.output) assert payload["dry_run"] is True - assert payload["summary"]["directories"] == 5 - assert payload["summary"]["files"] == 6 - assert ".specleft/SKILL.md" in payload["would_create"] - assert ".specleft/SKILL.md.sha256" in payload["would_create"] - assert ".specleft/specs/example-feature.md" in payload["would_create"] - assert ".specleft/templates/prd-template.yml" in payload["would_create"] + assert payload["directories_planned"] == 5 + assert payload["files_planned"] == 6 + assert ".specleft/SKILL.md" in payload["files"] + assert ".specleft/SKILL.md.sha256" in payload["files"] + assert ".specleft/specs/example-feature.md" in payload["files"] + assert ".specleft/templates/prd-template.yml" in payload["files"] assert len(payload["skill_file_hash"]) == 64 - def test_init_json_requires_dry_run(self) -> None: + def test_init_json_supports_non_dry_run(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): result = runner.invoke(cli, ["init", "--format", "json"]) - assert result.exit_code == 1 + assert result.exit_code == 0 payload = json.loads(result.output) - assert payload["status"] == "error" + assert payload["success"] is True def test_init_blank_creates_directories(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): - result = runner.invoke(cli, ["init", "--blank"]) + result = runner.invoke(cli, ["init", "--blank", "--format", "table"]) assert result.exit_code == 0 assert Path(".specleft/specs").exists() assert Path("tests").exists() @@ -76,7 +76,7 @@ def test_init_existing_features_skip(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): Path(".specleft/specs").mkdir(parents=True) - result = runner.invoke(cli, ["init"], input="1\n") + result = runner.invoke(cli, ["init", "--format", "table"], input="1\n") assert result.exit_code == 0 assert "Skipping initialization" in result.output assert Path(".specleft/specs").exists() @@ -86,7 +86,7 @@ def test_init_existing_features_merge(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): Path(".specleft/specs").mkdir(parents=True) - result = runner.invoke(cli, ["init"], input="2\n") + result = runner.invoke(cli, ["init", "--format", "table"], input="2\n") assert result.exit_code == 0 assert Path(".specleft/specs/example-feature.md").exists() @@ -94,7 +94,7 @@ def test_init_existing_features_cancel(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): Path(".specleft/specs").mkdir(parents=True) - result = runner.invoke(cli, ["init"], input="3\n") + result = runner.invoke(cli, ["init", "--format", "table"], input="3\n") assert result.exit_code == 2 assert "Cancelled" in result.output @@ -103,7 +103,7 @@ def test_init_existing_skill_file_warns_and_continues(self) -> None: with runner.isolated_filesystem(): Path(".specleft").mkdir(parents=True) Path(".specleft/SKILL.md").write_text("# existing\n") - result = runner.invoke(cli, ["init"]) + result = runner.invoke(cli, ["init", "--format", "table"]) assert result.exit_code == 0 assert ( "Warning: Skipped creation. Specleft SKILL.md exists already." diff --git a/tests/commands/test_plan.py b/tests/commands/test_plan.py index 4f6fdce..a93c6d4 100644 --- a/tests/commands/test_plan.py +++ b/tests/commands/test_plan.py @@ -23,7 +23,7 @@ class TestPlanCommand: def test_plan_missing_prd_warns(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): - result = runner.invoke(cli, ["plan"]) + result = runner.invoke(cli, ["plan", "--format", "table"]) assert result.exit_code == 0 assert "PRD not found" in result.output assert "Expected locations" in result.output @@ -34,7 +34,7 @@ def test_plan_creates_features_from_h2(self) -> None: Path("prd.md").write_text( "# PRD\n\n## Feature: User Authentication\n## Feature: Payments\n" ) - result = runner.invoke(cli, ["plan"]) + result = runner.invoke(cli, ["plan", "--format", "table"]) assert result.exit_code == 0 assert Path(".specleft/specs/feature-user-authentication.md").exists() @@ -51,7 +51,7 @@ def test_plan_uses_h1_when_no_h2(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): Path("prd.md").write_text("# User Authentication\n") - result = runner.invoke(cli, ["plan"]) + result = runner.invoke(cli, ["plan", "--format", "table"]) assert result.exit_code == 0 assert Path(".specleft/specs/user-authentication.md").exists() assert "using top-level title" in result.output @@ -60,7 +60,7 @@ def test_plan_defaults_to_prd_file_when_no_headings(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): Path("prd.md").write_text("No headings here") - result = runner.invoke(cli, ["plan"]) + result = runner.invoke(cli, ["plan", "--format", "table"]) assert result.exit_code == 0 assert Path(".specleft/specs/prd.md").exists() assert "creating .specleft/specs/prd.md" in result.output @@ -69,7 +69,7 @@ def test_plan_dry_run_creates_nothing(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): Path("prd.md").write_text("# User Authentication\n") - result = runner.invoke(cli, ["plan", "--dry-run"]) + result = runner.invoke(cli, ["plan", "--dry-run", "--format", "table"]) assert result.exit_code == 0 assert not Path(".specleft/specs").exists() assert "Dry run" in result.output @@ -105,7 +105,7 @@ def test_plan_skips_existing_feature(self) -> None: feature_file.write_text("# Feature: User Authentication\n") Path("prd.md").write_text("# User Authentication\n") - result = runner.invoke(cli, ["plan"]) + result = runner.invoke(cli, ["plan", "--format", "table"]) assert result.exit_code == 0 assert "Skipped existing" in result.output assert feature_file.read_text() == "# Feature: User Authentication\n" @@ -274,7 +274,7 @@ def test_plan_scenario_without_priority_gets_medium(self) -> None: "- When they request a refund\n" "- Then we mark it pending\n" ) - result = runner.invoke(cli, ["plan"]) + result = runner.invoke(cli, ["plan", "--format", "table"]) assert result.exit_code == 0 feature_file = Path(".specleft/specs/feature-billing.md") @@ -291,7 +291,7 @@ def test_analyze_flag_is_recognized(self) -> None: Path("prd.md").write_text( "# PRD\n\n## Overview\n\n## Feature: Billing\n\n## Notes\n\n## Payments\n" ) - result = runner.invoke(cli, ["plan", "--analyze"]) + result = runner.invoke(cli, ["plan", "--analyze", "--format", "table"]) assert result.exit_code == 0 assert not Path("features").exists() @@ -370,7 +370,9 @@ def test_plan_uses_custom_template_patterns(self) -> None: p0: critical """.lstrip()) - result = runner.invoke(cli, ["plan", "--template", "template.yml"]) + result = runner.invoke( + cli, ["plan", "--template", "template.yml", "--format", "table"] + ) assert result.exit_code == 0 feature_file = Path(".specleft/specs/epic-billing.md") @@ -419,7 +421,7 @@ def test_auto_detects_template_when_present(self) -> None: with runner.isolated_filesystem(): Path("prd.md").write_text("# PRD\n\n## Feature: Billing\n") self._write_default_template() - result = runner.invoke(cli, ["plan"]) + result = runner.invoke(cli, ["plan", "--format", "table"]) assert result.exit_code == 0 assert ( @@ -442,7 +444,7 @@ def test_no_template_message_when_file_absent(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): Path("prd.md").write_text("# PRD\n\n## Feature: Billing\n") - result = runner.invoke(cli, ["plan"]) + result = runner.invoke(cli, ["plan", "--format", "table"]) assert result.exit_code == 0 assert "Using template:" not in result.output @@ -459,7 +461,9 @@ def test_explicit_template_overrides_auto_detect(self) -> None: " patterns:\n" ' - "Epic: {title}"\n' ) - result = runner.invoke(cli, ["plan", "--template", "custom.yml"]) + result = runner.invoke( + cli, ["plan", "--template", "custom.yml", "--format", "table"] + ) assert result.exit_code == 0 assert "Using template: custom.yml" in result.output diff --git a/tests/commands/test_status.py b/tests/commands/test_status.py index e353260..3d91447 100644 --- a/tests/commands/test_status.py +++ b/tests/commands/test_status.py @@ -24,7 +24,7 @@ def test_status_json_includes_execution_time(self) -> None: scenario_id="login-success", execution_time="slow", ) - result = runner.invoke(cli, ["status", "--format", "json"]) + result = runner.invoke(cli, ["status", "--format", "json", "--verbose"]) assert result.exit_code == 0 payload = json.loads(result.output) scenarios = payload["features"][0]["scenarios"] @@ -38,7 +38,7 @@ def test_status_groups_by_feature_file(self) -> None: feature_id="auth", scenario_id="login-success", ) - result = runner.invoke(cli, ["status"]) + result = runner.invoke(cli, ["status", "--format", "table"]) assert result.exit_code == 0 assert ".specleft/specs/auth.md" in result.output assert "login-success" in result.output @@ -52,7 +52,9 @@ def test_status_unimplemented_table_output(self) -> None: story_id="login", scenario_id="login-success", ) - result = runner.invoke(cli, ["status", "--unimplemented"]) + result = runner.invoke( + cli, ["status", "--unimplemented", "--format", "table"] + ) assert result.exit_code == 0 assert "Unimplemented Scenarios" in result.output assert "⚠ auth/login/login-success" in result.output @@ -80,7 +82,7 @@ def test_status_treats_missing_test_file_as_skipped(self) -> None: story_id="login", scenario_id="login-success", ) - result = runner.invoke(cli, ["status", "--format", "json"]) + result = runner.invoke(cli, ["status", "--format", "json", "--verbose"]) payload = json.loads(result.output) scenario = payload["features"][0]["scenarios"][0] assert scenario["status"] == "skipped" @@ -105,7 +107,7 @@ def test_login_success(): pass """) - result = runner.invoke(cli, ["status", "--format", "json"]) + result = runner.invoke(cli, ["status", "--format", "json", "--verbose"]) scenario = json.loads(result.output)["features"][0]["scenarios"][0] assert scenario["status"] == "skipped" assert scenario["reason"] == "Not implemented" @@ -129,7 +131,7 @@ def test_login_success(): pass """) - result = runner.invoke(cli, ["status", "--format", "json"]) + result = runner.invoke(cli, ["status", "--format", "json", "--verbose"]) scenario = json.loads(result.output)["features"][0]["scenarios"][0] assert scenario["status"] == "implemented" @@ -143,7 +145,7 @@ def test_status_json_canonical_shape(self) -> None: story_id="login", scenario_id="login-success", ) - result = runner.invoke(cli, ["status", "--format", "json"]) + result = runner.invoke(cli, ["status", "--format", "json", "--verbose"]) assert result.exit_code == 0 payload = json.loads(result.output) diff --git a/tests/commands/test_test_report.py b/tests/commands/test_test_report.py index f918f5a..76df44a 100644 --- a/tests/commands/test_test_report.py +++ b/tests/commands/test_test_report.py @@ -81,7 +81,7 @@ def test_report_no_results(self) -> None: """Test report command when no results exist.""" runner = CliRunner() with runner.isolated_filesystem(): - result = runner.invoke(cli, ["test", "report"]) + result = runner.invoke(cli, ["test", "report", "--format", "table"]) assert result.exit_code == 1 assert "No results found" in result.output @@ -94,7 +94,7 @@ def test_report_generates_html(self, sample_results: dict) -> None: results_file = results_dir / "results_20250113_100000.json" results_file.write_text(json.dumps(sample_results)) - result = runner.invoke(cli, ["test", "report"]) + result = runner.invoke(cli, ["test", "report", "--format", "table"]) assert result.exit_code == 0 assert "Report generated" in result.output @@ -114,7 +114,9 @@ def test_report_custom_output(self, sample_results: dict) -> None: results_file = results_dir / "results_20250113_100000.json" results_file.write_text(json.dumps(sample_results)) - result = runner.invoke(cli, ["test", "report", "-o", "custom_report.html"]) + result = runner.invoke( + cli, ["test", "report", "-o", "custom_report.html", "--format", "table"] + ) assert result.exit_code == 0 assert Path("custom_report.html").exists() @@ -124,7 +126,9 @@ def test_report_specific_results_file(self, sample_results: dict) -> None: with runner.isolated_filesystem(): Path("my_results.json").write_text(json.dumps(sample_results)) - result = runner.invoke(cli, ["test", "report", "-r", "my_results.json"]) + result = runner.invoke( + cli, ["test", "report", "-r", "my_results.json", "--format", "table"] + ) assert result.exit_code == 0 assert Path("report.html").exists() @@ -161,7 +165,7 @@ def test_report_uses_latest_file(self, sample_results: dict) -> None: json.dumps({**sample_results, "run_id": "new-run"}) ) - result = runner.invoke(cli, ["test", "report"]) + result = runner.invoke(cli, ["test", "report", "--format", "table"]) assert result.exit_code == 0 content = Path("report.html").read_text() diff --git a/tests/commands/test_test_skeleton.py b/tests/commands/test_test_skeleton.py index ab083b1..14f86cd 100644 --- a/tests/commands/test_test_skeleton.py +++ b/tests/commands/test_test_skeleton.py @@ -31,7 +31,7 @@ def test_skeleton_missing_features_dir(self) -> None: """Test skeleton command when features directory is missing.""" runner = CliRunner() with runner.isolated_filesystem(): - result = runner.invoke(cli, ["test", "skeleton"]) + result = runner.invoke(cli, ["test", "skeleton", "--format", "table"]) assert result.exit_code == 1 assert "not found" in result.output @@ -50,6 +50,8 @@ def test_skeleton_generates_single_file(self) -> None: [ "test", "skeleton", + "--format", + "table", "--single-file", ], input="y\n", @@ -84,7 +86,9 @@ def test_skeleton_auto_detects_single_file_layout(self) -> None: scenario_id="card-charge", ) - result = runner.invoke(cli, ["test", "skeleton"], input="y\n") + result = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="y\n" + ) assert result.exit_code == 0 assert "Confirm creation?" in result.output assert "✓ Created 1 test files" in result.output @@ -108,7 +112,9 @@ def test_skeleton_auto_detects_nested_layout(self) -> None: scenario_id="legacy-charge", ) - result = runner.invoke(cli, ["test", "skeleton"], input="y\n") + result = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="y\n" + ) assert result.exit_code == 0 assert "Confirm creation?" in result.output assert "✓ Created 1 test files" in result.output @@ -131,6 +137,8 @@ def test_skeleton_custom_output_dir(self) -> None: [ "test", "skeleton", + "--format", + "table", "--output-dir", "custom_tests", ], @@ -155,7 +163,9 @@ def test_skeleton_custom_features_dir(self) -> None: ) result = runner.invoke( - cli, ["test", "skeleton", "-f", str(features_dir)], input="y\n" + cli, + ["test", "skeleton", "--format", "table", "-f", str(features_dir)], + input="y\n", ) assert result.exit_code == 0 assert "Confirm creation?" in result.output @@ -179,6 +189,8 @@ def test_skeleton_with_parameterized_tests(self) -> None: [ "test", "skeleton", + "--format", + "table", "--single-file", ], input="y\n", @@ -201,7 +213,15 @@ def test_skeleton_invalid_dir(self) -> None: Path(".specleft/specs").mkdir(parents=True) result = runner.invoke( - cli, ["test", "skeleton", "--features-dir", ".specleft/specs"] + cli, + [ + "test", + "skeleton", + "--format", + "table", + "--features-dir", + ".specleft/specs", + ], ) assert result.exit_code == 0 assert "No specs found" in result.output @@ -217,7 +237,15 @@ def test_skeleton_invalid_schema(self) -> None: (feature_dir / "_feature.md").write_text("---\nfeature_id: INVALID\n---") result = runner.invoke( - cli, ["test", "skeleton", "--features-dir", ".specleft/specs"] + cli, + [ + "test", + "skeleton", + "--format", + "table", + "--features-dir", + ".specleft/specs", + ], ) assert result.exit_code == 1 assert "Error loading" in result.output @@ -237,7 +265,9 @@ def test_skeleton_dedupes_duplicate_scenario_ids(self) -> None: scenario_id="example", ) - result = runner.invoke(cli, ["test", "skeleton"], input="n\n") + result = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="n\n" + ) assert result.exit_code == 2 assert "Duplicate scenario name found" in result.output assert "Scenario IDs:" in result.output @@ -273,7 +303,9 @@ def test_skeleton_shows_next_steps(self) -> None: scenario_id="login-success", ) - result = runner.invoke(cli, ["test", "skeleton"], input="n\n") + result = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="n\n" + ) assert result.exit_code == 2 assert "Confirm creation?" in result.output assert "Cancelled" in result.output @@ -289,7 +321,9 @@ def test_skeleton_preview_output(self) -> None: scenario_id="login-success", ) - result = runner.invoke(cli, ["test", "skeleton"], input="n\n") + result = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="n\n" + ) assert result.exit_code == 2 assert "File: tests/test_auth.py" in result.output assert "Scenario IDs: login-success" in result.output @@ -309,13 +343,17 @@ def test_skeleton_skips_existing_file(self) -> None: scenario_id="login-success", ) - first_run = runner.invoke(cli, ["test", "skeleton"], input="y\n") + first_run = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="y\n" + ) assert first_run.exit_code == 0 generated_file = Path("tests/test_auth.py") assert generated_file.exists() initial_content = generated_file.read_text() - second_run = runner.invoke(cli, ["test", "skeleton"], input="y\n") + second_run = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="y\n" + ) assert second_run.exit_code == 0 assert "No new skeleton tests to generate." in second_run.output assert generated_file.read_text() == initial_content @@ -331,7 +369,9 @@ def test_skeleton_tests_are_skipped_in_pytest(self) -> None: ) # Generate skeleton test - result = runner.invoke(cli, ["test", "skeleton"], input="y\n") + result = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="y\n" + ) assert result.exit_code == 0 assert "Confirm creation?" in result.output assert "✓ Created 1 test files" in result.output @@ -378,7 +418,9 @@ def test_skeleton_from_plan_style_spec(self) -> None: "- Then it should be stored\n" ) - result = runner.invoke(cli, ["test", "skeleton"], input="y\n") + result = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="y\n" + ) assert result.exit_code == 0 assert "Created 1 test files" in result.output @@ -409,7 +451,9 @@ def test_skeleton_from_bare_heading_spec(self) -> None: "- Then a push notification is sent\n" ) - result = runner.invoke(cli, ["test", "skeleton"], input="y\n") + result = runner.invoke( + cli, ["test", "skeleton", "--format", "table"], input="y\n" + ) assert result.exit_code == 0 assert "Created 1 test files" in result.output diff --git a/tests/commands/test_test_stub.py b/tests/commands/test_test_stub.py index 4ef6b2b..951038f 100644 --- a/tests/commands/test_test_stub.py +++ b/tests/commands/test_test_stub.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path from click.testing import CliRunner @@ -19,7 +20,7 @@ def test_stub_missing_features_dir(self) -> None: """Test stub command when features directory is missing.""" runner = CliRunner() with runner.isolated_filesystem(): - result = runner.invoke(cli, ["test", "stub"]) + result = runner.invoke(cli, ["test", "stub", "--format", "table"]) assert result.exit_code == 1 assert "not found" in result.output @@ -35,7 +36,7 @@ def test_stub_generates_single_file(self) -> None: result = runner.invoke( cli, - ["test", "stub", "--single-file"], + ["test", "stub", "--single-file", "--format", "table"], input="y\n", ) assert result.exit_code == 0 @@ -66,7 +67,9 @@ def test_stub_auto_detects_single_file_layout(self) -> None: scenario_id="card-charge", ) - result = runner.invoke(cli, ["test", "stub"], input="y\n") + result = runner.invoke( + cli, ["test", "stub", "--format", "table"], input="y\n" + ) assert result.exit_code == 0 assert "Confirm creation?" in result.output assert "✓ Created 1 test files" in result.output @@ -90,7 +93,9 @@ def test_stub_auto_detects_nested_layout(self) -> None: scenario_id="legacy-charge", ) - result = runner.invoke(cli, ["test", "stub"], input="y\n") + result = runner.invoke( + cli, ["test", "stub", "--format", "table"], input="y\n" + ) assert result.exit_code == 0 assert "Confirm creation?" in result.output assert "✓ Created 1 test files" in result.output @@ -115,6 +120,8 @@ def test_stub_custom_output_dir(self) -> None: "stub", "--output-dir", "custom_tests", + "--format", + "table", ], input="y\n", ) @@ -137,7 +144,9 @@ def test_stub_custom_features_dir(self) -> None: ) result = runner.invoke( - cli, ["test", "stub", "-f", str(features_dir)], input="y\n" + cli, + ["test", "stub", "-f", str(features_dir), "--format", "table"], + input="y\n", ) assert result.exit_code == 0 assert "Confirm creation?" in result.output @@ -154,7 +163,9 @@ def test_stub_dry_run(self) -> None: scenario_id="login-success", ) - result = runner.invoke(cli, ["test", "stub", "--dry-run"]) + result = runner.invoke( + cli, ["test", "stub", "--dry-run", "--format", "table"] + ) assert result.exit_code == 0 assert "Dry run: no files will be created." in result.output assert "Would create tests:" in result.output @@ -181,8 +192,10 @@ def test_stub_json_format(self) -> None: ], ) assert result.exit_code == 0 - assert '"would_create"' in result.output - assert '"preview"' in result.output + payload = json.loads(result.output) + assert payload["dry_run"] is True + assert payload["files_planned"] == 1 + assert "tests/test_auth.py" in payload["files"] def test_stub_force_overwrites(self) -> None: """Test stub command force overwrites existing files.""" @@ -194,7 +207,9 @@ def test_stub_force_overwrites(self) -> None: scenario_id="login-success", ) - first_run = runner.invoke(cli, ["test", "stub"], input="y\n") + first_run = runner.invoke( + cli, ["test", "stub", "--format", "table"], input="y\n" + ) assert first_run.exit_code == 0 generated_file = Path("tests/test_auth.py") assert generated_file.exists() @@ -202,7 +217,7 @@ def test_stub_force_overwrites(self) -> None: second_run = runner.invoke( cli, - ["test", "stub", "--force"], + ["test", "stub", "--force", "--format", "table"], input="y\n", ) assert second_run.exit_code == 0 @@ -220,7 +235,7 @@ def test_stub_does_not_include_steps(self) -> None: result = runner.invoke( cli, - ["test", "stub", "--single-file"], + ["test", "stub", "--single-file", "--format", "table"], input="y\n", ) assert result.exit_code == 0 @@ -240,7 +255,7 @@ def test_stub_includes_decorator(self) -> None: result = runner.invoke( cli, - ["test", "stub", "--single-file"], + ["test", "stub", "--single-file", "--format", "table"], input="y\n", ) assert result.exit_code == 0 @@ -262,7 +277,7 @@ def test_stub_includes_parametrize(self) -> None: result = runner.invoke( cli, - ["test", "stub", "--single-file"], + ["test", "stub", "--single-file", "--format", "table"], input="y\n", ) assert result.exit_code == 0 From 820946a355cba9a3ecaa73dc78f0bb6f9a9f631a Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Sun, 15 Feb 2026 21:28:10 +0000 Subject: [PATCH 2/4] Remove duplicate status summary totals (#94) --- src/specleft/commands/status.py | 3 --- .../acceptance/test_feature-4-status-coverage-inspection.py | 6 ++---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/specleft/commands/status.py b/src/specleft/commands/status.py index 38c59df..84ceb43 100644 --- a/src/specleft/commands/status.py +++ b/src/specleft/commands/status.py @@ -183,9 +183,6 @@ def build_status_json( "features": summary.total_features, "stories": summary.total_stories, "scenarios": summary.total_scenarios, - "total_features": summary.total_features, - "total_stories": summary.total_stories, - "total_scenarios": summary.total_scenarios, "implemented": summary.implemented, "skipped": summary.skipped, "coverage_percent": summary.coverage_percent, diff --git a/tests/acceptance/test_feature-4-status-coverage-inspection.py b/tests/acceptance/test_feature-4-status-coverage-inspection.py index a980a49..9247c1c 100644 --- a/tests/acceptance/test_feature-4-status-coverage-inspection.py +++ b/tests/acceptance/test_feature-4-status-coverage-inspection.py @@ -250,7 +250,5 @@ def test_status_of_implementation_by_feature( # Verify overall summary only counts the filtered feature assert "summary" in payload, "Expected 'summary' in payload" overall = payload["summary"] - assert overall["total_features"] == 1, "Should only count filtered feature" - assert ( - overall["total_scenarios"] == 2 - ), "Should only count scenarios from filtered feature" + assert overall["features"] == 1, "Should only count filtered feature" + assert overall["scenarios"] == 2, "Should only count scenarios from filtered feature" From adc7bafa807de3cbf1a3b49e661e060288e7d4f4 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Sun, 15 Feb 2026 21:44:36 +0000 Subject: [PATCH 3/4] Format feature 4 acceptance test for lint --- tests/acceptance/test_feature-4-status-coverage-inspection.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/acceptance/test_feature-4-status-coverage-inspection.py b/tests/acceptance/test_feature-4-status-coverage-inspection.py index 9247c1c..0b9a55d 100644 --- a/tests/acceptance/test_feature-4-status-coverage-inspection.py +++ b/tests/acceptance/test_feature-4-status-coverage-inspection.py @@ -251,4 +251,6 @@ def test_status_of_implementation_by_feature( assert "summary" in payload, "Expected 'summary' in payload" overall = payload["summary"] assert overall["features"] == 1, "Should only count filtered feature" - assert overall["scenarios"] == 2, "Should only count scenarios from filtered feature" + assert ( + overall["scenarios"] == 2 + ), "Should only count scenarios from filtered feature" From 1fa3ebe7e84d5337dbda20b6e90c9b1feebdef94 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Sun, 15 Feb 2026 22:01:42 +0000 Subject: [PATCH 4/4] Adjust doctor CLI info --- src/specleft/commands/doctor.py | 12 ++++++++++-- tests/commands/test_doctor.py | 15 ++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/specleft/commands/doctor.py b/src/specleft/commands/doctor.py index eb4064d..6de6c2a 100644 --- a/src/specleft/commands/doctor.py +++ b/src/specleft/commands/doctor.py @@ -16,7 +16,11 @@ import click from specleft.commands.constants import CLI_VERSION -from specleft.commands.output import json_dumps, resolve_output_format +from specleft.commands.output import ( + compact_mode_enabled, + json_dumps, + resolve_output_format, +) from specleft.utils.messaging import print_support_footer from specleft.utils.skill_integrity import ( INTEGRITY_MODIFIED, @@ -57,7 +61,11 @@ def _load_dependency_names() -> list[str]: def _build_doctor_checks(*, verify_skill: bool) -> dict[str, Any]: import importlib.metadata as metadata - cli_check = {"status": "pass", "version": CLI_VERSION} + cli_check = { + "status": "pass", + "version": CLI_VERSION, + "compact_mode": compact_mode_enabled(), + } python_info = sys.version_info minimum_python = (3, 9, 0) diff --git a/tests/commands/test_doctor.py b/tests/commands/test_doctor.py index 8ff6bb8..97321a4 100644 --- a/tests/commands/test_doctor.py +++ b/tests/commands/test_doctor.py @@ -51,12 +51,25 @@ def test_dependency_parsing(self, tmp_path: Path) -> None: def test_doctor_json_includes_version(self) -> None: runner = CliRunner() - result = runner.invoke(cli, ["doctor", "--format", "json"]) + result = runner.invoke( + cli, ["doctor", "--format", "json"], env={"SPECLEFT_COMPACT": "0"} + ) assert result.exit_code in {0, 1} payload = json.loads(result.output) assert payload["version"] == CLI_VERSION assert "healthy" in payload assert "checks" in payload + assert payload["checks"]["cli_available"]["version"] == CLI_VERSION + assert payload["checks"]["cli_available"]["compact_mode"] is False + + def test_doctor_json_compact_mode_true(self) -> None: + runner = CliRunner() + result = runner.invoke( + cli, ["doctor", "--format", "json"], env={"SPECLEFT_COMPACT": "1"} + ) + assert result.exit_code in {0, 1} + payload = json.loads(result.output) + assert payload["checks"]["cli_available"]["compact_mode"] is True def test_doctor_table_output(self) -> None: runner = CliRunner()