Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions docs/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
28 changes: 15 additions & 13 deletions src/specleft/commands/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

Expand All @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions src/specleft/commands/contracts/payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
],
Expand Down
12 changes: 9 additions & 3 deletions src/specleft/commands/contracts/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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
)
Expand Down
53 changes: 43 additions & 10 deletions src/specleft/commands/coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

from __future__ import annotations

import json
import sys
from datetime import datetime
from pathlib import Path
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -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()
Expand Down
Loading
Loading