feat(cli): overhaul commands and add live scan status (#424)

* refactor(cli): list workspaces natively instead of via the worker image

* feat(cli): preflight that Docker is installed and running

* feat(cli): stop scans by workspace or --all, terminating their Temporal workflows

* fix(worker): abort the running agent on cancellation so Temporal cancel takes effect

* refactor(cli): split destructive teardown out of stop into a reset command

* refactor(cli): centralise flag parsing and confirmation across commands

* fix(cli): pass provider credentials to docker by name to keep secrets out of argv

* feat(cli): add per-command help via <command> --help/-h and help <command>

* feat(cli): replace raw docker output with clack spinners for infra and scan teardown

* fix(cli): verify scan stop by re-querying container and workflow state instead of assuming success

* fix(cli): resolve running state before prompting on stop and report no-op stops honestly

* refactor(cli): show splash first and drive start with one spinner resolving to a clean line

* fix(cli): validate --url up front so a bad value fails cleanly instead of a late crash

* refactor(cli): centralize error reporting with fail() for expected errors and a crash handler that logs the stack and links the issue tracker

* feat(cli): add --json/--plain machine-readable output to workspaces and status

* refactor(cli): remove the workspaces command

* refactor(cli): remove the status command

* feat(cli): add 'progress <workspace>' — live scan progress from Temporal

* fix(cli): mark metric-less agents as skipped in progress, not done

* feat(cli): animate running agents in progress with a clack-style spinner

* feat(cli): rename progress->status, reveal agents as they run, show live per-agent elapsed

* fix(cli): mark passed-over phases as skipped live, not pending

* style(cli): rename status footer 'Wall-clock' to 'Time Taken', drop the parenthetical

* style(cli): drop '(sum of agents)' from status total cost line

* style(cli): green filled circle for completed, Shannon gold for running

* style(cli): use Shannon gold in place of green in status

* feat(cli): suggest closest command or flag on typo

* refactor(cli): single-source start help and drop ./repos bare-name shortcut

* feat(cli): name providers and fix in multi-provider credential error

* feat(cli): support --flag=value syntax and expand leading ~ in paths

* refactor(cli): centralize ANSI color codes in colors.ts

* feat(cli): add scans command listing completed scans with cost and duration

* fix(cli): keep stdout clean off-TTY for logs and start

* feat(cli): add repo link to top-level help

* feat(worker): record auth-validation metrics and register resume attempts early

* refactor(cli): share resume-aware workflow-id resolution and surface root-cause failures

* feat(cli): add status --json, auth phase, dashboard link, and stable live redraw

* refactor(cli): drop cost from status and scans output

* feat(worker): surface both PDF and markdown report at run root

* refactor(cli): normalize error/warning prefixing through fail and warn

* feat(cli): add version --json for machine-readable output

* refactor(cli): rename start --debug to --keep-container

* refactor(cli): point start's progress hint at status instead of the Temporal dashboard

* refactor(cli): centralize the mode-aware command prefix

* refactor(cli): trim start and logs output to durable facts off-TTY

* feat(cli): require typed confirmation for reset instead of --yes

reset permanently wipes all Temporal data and volumes — a severe,
irreversible action. Replace its default y/N confirm (bypassable with
--yes) with a typed-word confirmation that has no bypass, so the wipe
can only be triggered by a deliberate interactive answer.

* feat(cli): surface logs and status hints after start on a TTY

* feat(cli): exit 2 on usage errors, distinct from operational failures

* feat(cli): add start --follow to stream logs and exit on scan outcome

* refactor(cli): redesign splash with sunset-gradient wordmark and truecolor

* refactor(cli): remove the uninstall command

* docs: sync CLI docs with removed uninstall/workspaces, new scans and --follow

* docs: fix reset confirmation — typed confirm, not --yes/-y

* style(cli): restructure status footer with divider, aligned Logs/Temporal rows

* feat(cli): show splash in the status command

* fix(worker): validate auth-state shape, not entry count

* docs: correct reset confirmation and add markdown report to run-root docs
This commit is contained in:
ezl-keygraph
2026-08-18 15:46:25 +05:30
committed by GitHub
parent 1ae0a142f8
commit d41ae9c20d
44 changed files with 2605 additions and 801 deletions
+12 -2
View File
@@ -9,6 +9,7 @@ import {
ASSEMBLED_REPORT_FILENAME,
ASSEMBLED_REPORT_PDF_FILENAME,
deliverablesDir,
FINAL_REPORT_MD_FILENAME,
FINAL_REPORT_PDF_FILENAME,
resolveSessionJsonPath,
SARIF_FILENAME,
@@ -175,8 +176,8 @@ export async function injectModelIntoReport(
/**
* Surface the run's deliverables at the run directory's top level, so a customer opening the run
* folder sees the report without digging through internals. Sources stay in the deliverables dir
* (git-checkpointed, used by resume). The PDF is the customer-facing report surfaced here; the
* markdown remains in the deliverables dir but is not surfaced.
* (git-checkpointed, used by resume). Both the PDF and the markdown report are surfaced here as the
* customer-facing copies.
*
* The SARIF log is surfaced beside it when present, since a CI step consuming it needs a stable
* path and cannot be expected to reach into the internals directory. It is absent whenever the
@@ -199,6 +200,15 @@ export async function copyReportToRunRoot(
logger.warn(`PDF report not found, skipping ${FINAL_REPORT_PDF_FILENAME}`);
}
const markdownSource = path.join(dir, ASSEMBLED_REPORT_FILENAME);
if (await fs.pathExists(markdownSource)) {
const destination = path.join(runDir, FINAL_REPORT_MD_FILENAME);
await fs.copy(markdownSource, destination, { overwrite: true });
logger.info(`Surfaced markdown report at ${destination}`);
} else {
logger.warn(`Markdown report not found, skipping ${FINAL_REPORT_MD_FILENAME}`);
}
const sarifSource = path.join(dir, SARIF_FILENAME);
if (await fs.pathExists(sarifSource)) {
const sarifDestination = path.join(runDir, SARIF_FILENAME);
@@ -23,6 +23,7 @@ import type { ActivityLogger } from '../types/activity-logger.js';
import type { AgentEndResult } from '../types/audit.js';
import type { DistributedConfig } from '../types/config.js';
import { ErrorCode } from '../types/errors.js';
import type { AgentMetrics } from '../types/metrics.js';
import { err, ok, type Result } from '../types/result.js';
import { PentestError } from './error-handling.js';
import { loadPrompt } from './prompt-manager.js';
@@ -97,7 +98,9 @@ export interface ValidateAuthInput {
readonly cancellationSignal?: AbortSignal;
}
export async function validateAuthentication(input: ValidateAuthInput): Promise<Result<void, PentestError>> {
export async function validateAuthentication(
input: ValidateAuthInput,
): Promise<Result<AgentMetrics | null, PentestError>> {
const {
distributedConfig,
repoPath,
@@ -113,7 +116,7 @@ export async function validateAuthentication(input: ValidateAuthInput): Promise<
const authentication = distributedConfig.authentication;
if (!authentication) {
return ok(undefined);
return ok(null);
}
logger.info('Validating authentication credentials with live browser...', {
@@ -160,9 +163,10 @@ export async function validateAuthentication(input: ValidateAuthInput): Promise<
}
}
const durationMs = Date.now() - startTime;
const endResult: AgentEndResult = {
attemptNumber,
duration_ms: Date.now() - startTime,
duration_ms: durationMs,
cost_usd: result.cost || 0,
success: classification.ok,
...(result.model !== undefined && { model: result.model }),
@@ -170,7 +174,21 @@ export async function validateAuthentication(input: ValidateAuthInput): Promise<
};
await auditSession.endAgent(AGENT_NAME, endResult);
return classification;
if (!classification.ok) {
return err(classification.error);
}
const metrics: AgentMetrics = {
durationMs,
inputTokens: result.inputTokens ?? null,
outputTokens: result.outputTokens ?? null,
cacheReadTokens: result.cacheReadTokens ?? null,
cacheWriteTokens: result.cacheWriteTokens ?? null,
costUsd: result.cost ?? null,
numTurns: result.turns ?? null,
...(result.model !== undefined && { model: result.model }),
};
return ok(metrics);
}
async function verifySavedAuthState(stateFile: string, logger: ActivityLogger): Promise<Result<void, PentestError>> {
@@ -205,28 +223,32 @@ async function verifySavedAuthState(stateFile: string, logger: ActivityLogger):
);
}
const cookieCount = countStorageEntries(parsed, 'cookies');
const originCount = countStorageEntries(parsed, 'origins');
if (cookieCount === 0 && originCount === 0) {
const cookies = storageEntries(parsed, 'cookies');
const origins = storageEntries(parsed, 'origins');
if (!cookies || !origins) {
return err(
new PentestError(
`Preflight saved an authenticated session to ${stateFile}, but it contains no cookies or origins — the browser was not actually logged in.`,
`Preflight saved an authenticated session to ${stateFile}, but it is not a storage state — cookies and origins arrays are missing.`,
'validation',
true,
{ stateFile, cookieCount, originCount },
{ stateFile, hasCookies: !!cookies, hasOrigins: !!origins },
ErrorCode.AGENT_EXECUTION_FAILED,
),
);
}
logger.info('Preflight authenticated session saved', { stateFile, cookieCount, originCount });
logger.info('Preflight authenticated session saved', {
stateFile,
cookieCount: cookies.length,
originCount: origins.length,
});
return ok(undefined);
}
function countStorageEntries(parsed: unknown, key: 'cookies' | 'origins'): number {
if (typeof parsed !== 'object' || parsed === null) return 0;
function storageEntries(parsed: unknown, key: 'cookies' | 'origins'): unknown[] | null {
if (typeof parsed !== 'object' || parsed === null) return null;
const value = (parsed as Record<string, unknown>)[key];
return Array.isArray(value) ? value.length : 0;
return Array.isArray(value) ? value : null;
}
function classifyResult(