From f1386cad37964197a483ff43c25161f9d933f050 Mon Sep 17 00:00:00 2001 From: Ian Date: Mon, 5 Oct 2026 15:35:20 -0400 Subject: [PATCH] feat(cli): add -r for --resume and a title column to the resume picker --- .../penetration-testing-with-strix/SKILL.md | 2 +- strix/interface/cli_args.py | 1 + strix/interface/resume_picker.py | 29 ++++++++++++------- strix/report/runs.py | 16 ++++++++++ tests/test_cli_resume_picker.py | 7 +++-- tests/test_resume_picker.py | 6 +++- tests/test_run_summaries.py | 22 ++++++++++++++ 7 files changed, 68 insertions(+), 15 deletions(-) diff --git a/skills/penetration-testing-with-strix/SKILL.md b/skills/penetration-testing-with-strix/SKILL.md index 475601c7..afd56cea 100644 --- a/skills/penetration-testing-with-strix/SKILL.md +++ b/skills/penetration-testing-with-strix/SKILL.md @@ -95,7 +95,7 @@ Key flags: | `--max-budget USD` | Hard LLM spend cap; scan wraps up cleanly at the limit. | | `--max-turns N` | Per-agent turn cap (default 500). | | `--fail-on SEVERITY` | Headless only: exit `2` only for findings at or above `critical`/`high`/`medium`/`low`/`info`. Default: any finding. | -| `--resume RUN_NAME` | Resume a prior run from `strix_runs/`, with its agent history and targets. Cannot be combined with `-t`. | +| `-r`, `--resume RUN_NAME` | Resume a prior run from `strix_runs/`, with its agent history and targets. Cannot be combined with `-t`. | | `--scope-mode` | For code targets: `auto` (diff-scope in CI/headless), `diff` (force changed files only), `full` (whole tree). | | `--diff-base REF` | Branch or commit that `diff` scope compares against. Defaults to the repo's default branch. | diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index a121a2af..c9b6951b 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -299,6 +299,7 @@ Strix Cloud: ) parser.add_argument( + "-r", "--resume", type=str, nargs="?", diff --git a/strix/interface/resume_picker.py b/strix/interface/resume_picker.py index bd9e6268..fb1e1796 100644 --- a/strix/interface/resume_picker.py +++ b/strix/interface/resume_picker.py @@ -175,6 +175,7 @@ def filter_runs(runs: list[RunSummary], needle: str) -> list[RunSummary]: for run in runs if needle in run.run_name.lower() or needle in run.target.lower() + or needle in run.title.lower() or needle in run.status.lower() ] @@ -215,16 +216,18 @@ class ResumePicker: def _visible(self) -> int: return max(3, min(len(self.rows), _MAX_VISIBLE, self.console.height - _CHROME_LINES)) - def _columns(self) -> tuple[int, int, int]: + def _columns(self) -> tuple[int, int, int, int]: width = max(40, self.console.width - 1) status_width = max(len("status"), *(len(_status_text(run)) for run in self.runs)) run_width = min(_MAX_RUN_WIDTH, max(len("run"), *(len(run.run_name) for run in self.runs))) - fixed = len(_CURSOR) + _STARTED_WIDTH + _FINDINGS_WIDTH + status_width + 4 * 2 + fixed = len(_CURSOR) + _STARTED_WIDTH + _FINDINGS_WIDTH + status_width + 5 * 2 target_width = width - fixed - run_width - if target_width < _MIN_TARGET_WIDTH: - run_width = max(8, run_width + target_width - _MIN_TARGET_WIDTH) + if target_width < 2 * _MIN_TARGET_WIDTH: + run_width = max(8, run_width + target_width - 2 * _MIN_TARGET_WIDTH) target_width = width - fixed - run_width - return max(_MIN_TARGET_WIDTH, target_width), status_width, run_width + title_width = max(_MIN_TARGET_WIDTH, target_width // 2) + target_width = max(_MIN_TARGET_WIDTH, target_width - title_width) + return title_width, target_width, status_width, run_width def _scroll(self) -> range: rows = self.rows @@ -250,7 +253,7 @@ class ResumePicker: title.append(self.filter) header = Text( " " * len(_CURSOR) - + self._cells("started", "target", "status", "findings", "run", widths), + + self._cells("started", "title", "target", "status", "findings", "run", widths), style="dim", ) lines = [title, Text(), header] @@ -276,16 +279,18 @@ class ResumePicker: @staticmethod def _cells( started: str, + title: str, target: str, status: str, findings: str, run: str, - widths: tuple[int, int, int], + widths: tuple[int, int, int, int], ) -> str: - target_width, status_width, run_width = widths + title_width, target_width, status_width, run_width = widths return " ".join( [ _fit(started, _STARTED_WIDTH), + _fit(title, title_width), _fit(target, target_width), _fit(status, status_width), _fit(findings, _FINDINGS_WIDTH), @@ -293,8 +298,8 @@ class ResumePicker: ] ) - def _row(self, run: RunSummary, selected: bool, widths: tuple[int, int, int]) -> Text: - target_width, status_width, run_width = widths + def _row(self, run: RunSummary, selected: bool, widths: tuple[int, int, int, int]) -> Text: + title_width, target_width, status_width, run_width = widths primary = "bold" if selected else "" muted = "" if selected else "dim" status_style = _STATUS_STYLES.get(run.status, "") @@ -304,7 +309,9 @@ class ResumePicker: line.append(_CURSOR if selected else " " * len(_CURSOR), style=_GREEN) line.append(_fit(relative_time(run.started_at, self.now), _STARTED_WIDTH), style=muted) line.append(" ") - line.append(_fit(run.target, target_width), style=primary) + line.append(_fit(run.title, title_width), style=primary) + line.append(" ") + line.append(_fit(run.target, target_width), style=muted) line.append(" ") line.append(_fit(_status_text(run), status_width), style=status_style) line.append(" ") diff --git a/strix/report/runs.py b/strix/report/runs.py index 1c46115c..ae27a60f 100644 --- a/strix/report/runs.py +++ b/strix/report/runs.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -22,6 +23,7 @@ class RunSummary: status: str findings: int resumable: bool + title: str = "" def list_run_summaries(*, cwd: Path | None = None) -> list[RunSummary]: @@ -55,9 +57,23 @@ def _summarize(run_dir: Path, record: Any) -> RunSummary: status=str(record.get("status") or "unknown"), findings=len(findings) if isinstance(findings, list) else 0, resumable=(runtime_state_dir(run_dir) / "agents.json").is_file(), + title=_title(record, findings), ) +_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3} + + +def _title(record: dict[str, Any], findings: Any) -> str: + """The instruction's first line, else the most severe finding's title, shortened.""" + instruction = str(record.get("user_instruction") or record.get("instruction") or "").strip() + if instruction: + return instruction.splitlines()[0] + found = [f for f in findings if isinstance(f, dict)] if isinstance(findings, list) else [] + worst = min(found, key=lambda f: _SEVERITY_RANK.get(str(f.get("severity")), 4), default={}) + return re.split(r" via | on | \(", str(worst.get("title") or ""))[0] + + def _describe_target(record: dict[str, Any]) -> str: targets = record.get("targets_info") originals = [ diff --git a/tests/test_cli_resume_picker.py b/tests/test_cli_resume_picker.py index ed0aeb05..4d00380f 100644 --- a/tests/test_cli_resume_picker.py +++ b/tests/test_cli_resume_picker.py @@ -41,10 +41,13 @@ def _write_run(base: Path, name: str, *, state: bool = True) -> None: (run_dir / ".state" / "agents.json").write_text("{}", encoding="utf-8") -def test_bare_resume_defers_to_the_picker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("flag", ["--resume", "-r"]) +def test_bare_resume_defers_to_the_picker( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, flag: str +) -> None: monkeypatch.chdir(tmp_path) _write_run(tmp_path / "strix_runs", "example-com_1111") - monkeypatch.setattr(sys, "argv", ["strix", "--resume"]) + monkeypatch.setattr(sys, "argv", ["strix", flag]) args = cli_main.parse_arguments() diff --git a/tests/test_resume_picker.py b/tests/test_resume_picker.py index 1999150f..31bbf9ec 100644 --- a/tests/test_resume_picker.py +++ b/tests/test_resume_picker.py @@ -38,6 +38,7 @@ def _run( status: str = "completed", findings: int = 0, resumable: bool = True, + title: str = "", ) -> RunSummary: started = (NOW - timedelta(minutes=minutes_ago)).isoformat() return RunSummary( @@ -48,6 +49,7 @@ def _run( status=status, findings=findings, resumable=resumable, + title=title, ) @@ -62,6 +64,7 @@ RUNS = [ minutes_ago=60 * 26, status="stopped", findings=7, + title="SQL injection", ), _run( "strix_41c0", @@ -127,7 +130,8 @@ def test_render_lists_every_run_with_its_metadata() -> None: for run in RUNS: assert run.run_name in text assert "12 min ago" in text - assert "https://juice-shop.herokuapp.com" in text + assert "https://juice-shop" in text + assert "SQL injection" in text assert "interrupted" in text assert "failed ยท no state" in text assert text.splitlines()[3].startswith(" \u276f ") diff --git a/tests/test_run_summaries.py b/tests/test_run_summaries.py index 30e6db29..2a238014 100644 --- a/tests/test_run_summaries.py +++ b/tests/test_run_summaries.py @@ -139,3 +139,25 @@ def test_blank_instruction_does_not_break_the_listing( [summary] = list_run_summaries() assert summary.target == "" + + +def test_title_is_the_instruction_else_the_most_severe_finding(tmp_path: Path) -> None: + base = tmp_path / "strix_runs" + _write_run( + base, "instructed_1111", {"user_instruction": "focus on auth\nand billing"}, modified=2 + ) + _write_run(base, "found_2222", {}, modified=1) + (base / "found_2222" / "vulnerabilities.json").write_text( + json.dumps( + [ + {"title": "Verbose errors", "severity": "low"}, + {"title": "SQL injection via id on /login", "severity": "critical"}, + ] + ), + encoding="utf-8", + ) + + assert [run.title for run in list_run_summaries(cwd=tmp_path)] == [ + "focus on auth", + "SQL injection", + ]