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
69 changes: 69 additions & 0 deletions gecko/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
)
from .access import public_session, stub_session
from .client import AgentApiClient
from .ingest import load_spec
from .modes import coerce_mode
from .netguard import UnsafeUrlError, validate_public_url

Expand All @@ -48,6 +49,7 @@
"serve",
"test",
"inspect",
"report",
"from-docs",
"scan-image",
"scan-doc",
Expand Down Expand Up @@ -286,6 +288,71 @@ def _cmd_inspect(argv: list[str]) -> int:
return 1 if (below or inspect_mod.has_blocking(report)) else 0


def _cmd_report(argv: list[str]) -> int:
"""`gecko report <spec>` — the Agent-Readiness Scorecard (offline, $0).

Thin transport over :mod:`gecko.report`. Without `--since`, write the self-contained
HTML scorecard (plus a sidecar JSON for later diffs). With `--since <prior.json>`,
print the drift delta (score change + broken/resolved call-paths).
"""
from . import inspect as inspect_mod
from . import report as report_mod

p = argparse.ArgumentParser(
prog="gecko report",
description="Build a self-contained agent-readiness scorecard (HTML) for an API.",
)
p.add_argument(
"spec",
help="An API domain, OpenAPI URL, docs URL, or local path — Gecko finds the spec.",
)
p.add_argument(
"-o",
"--out",
default=None,
help="Write the HTML here (default: <api>.scorecard.html).",
)
p.add_argument(
"--intent",
action="append",
default=None,
help="A plain-English intent to script the Playground (repeatable).",
)
p.add_argument(
"--since",
default=None,
help="A prior scorecard JSON — print the drift delta instead of writing HTML.",
)
args = p.parse_args(argv)
if _reject_unsafe(args.spec, "report"):
return 2
# Load once, directly — mirrors `gecko graph` (load_spec handles both a local
# yaml/json path and an SSRF-validated URL). The engine does the work.
try:
spec = load_spec(args.spec)
except (UnsafeUrlError, OSError, ValueError) as exc:
print(f" ✗ could not read spec at {args.spec}: {exc}", file=sys.stderr)
return 2

name = onboard.safe_name(args.spec)
if args.since:
old = json.loads(Path(args.since).read_text(encoding="utf-8"))
new = inspect_mod.inspect(spec, api=name).to_dict()
print(report_mod.render_diff(report_mod.report_diff(old, new)))
return 0

html = report_mod.build_scorecard(spec, intents=args.intent)
out = Path(args.out) if args.out else Path(f"{name}.scorecard.html")
out.write_text(html, encoding="utf-8")
sidecar = out.with_suffix(".json")
sidecar.write_text(
json.dumps(inspect_mod.inspect(spec, api=name).to_dict(), indent=2),
encoding="utf-8",
)
print(f" → wrote {out} ({len(html)} bytes) and {sidecar}")
return 0


def _cmd_test(argv: list[str]) -> int:
p = argparse.ArgumentParser(
prog="gecko test",
Expand Down Expand Up @@ -1364,6 +1431,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_test(rest)
if cmd == "inspect":
return _cmd_inspect(rest)
if cmd == "report":
return _cmd_report(rest)
if cmd == "from-docs":
return _cmd_from_docs(rest)
if cmd == "scan-image":
Expand Down
36 changes: 35 additions & 1 deletion gecko/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,40 @@ class InspectionReport:
score: int # 0-100 overall
dimensions: list[DimensionResult]
summary: str
#: Content hash of the inspected spec (``surfaces.surface_rev``) — lets a stored
#: report be diffed against a later one to catch drift. Defaults empty for callers
#: that build a report without a spec on hand (kept back-compat with positional use).
surface_rev: str = ""

def to_dict(self) -> dict[str, Any]:
"""JSON-serializable form so a report can be persisted and diffed (``report_diff``).

Control-plane only: structure, scores, and finding metadata — never a payload,
value, or secret."""
return {
"api": self.api,
"grade": self.grade,
"score": self.score,
"surface_rev": self.surface_rev,
"summary": self.summary,
"dimensions": [
{
"name": d.name,
"score": d.score,
"findings": [
{
"dimension": f.dimension,
"severity": f.severity,
"location": f.location,
"message": f.message,
"fix": f.fix,
}
for f in d.findings
],
}
for d in self.dimensions
],
}


# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -279,7 +313,7 @@ def inspect(spec: dict[str, Any], *, api: str) -> InspectionReport:
f"{api}: agent-readiness {grade} ({overall}/100) — "
f"{n_block} blocking, {n_warn} warnings"
)
return InspectionReport(api, grade, overall, dims, summary)
return InspectionReport(api, grade, overall, dims, summary, client.surface_rev)


def has_blocking(report: InspectionReport) -> bool:
Expand Down
Loading
Loading