diff --git a/.changeset/0000-renderers-ui-migration.md b/.changeset/0000-renderers-ui-migration.md new file mode 100644 index 0000000..1874057 --- /dev/null +++ b/.changeset/0000-renderers-ui-migration.md @@ -0,0 +1,5 @@ +--- +"@cdot65/prisma-airs-cli": patch +--- + +Migrated all CLI renderer modules (backup, eval, redteam, runtime, dlp, model-security) to the shared ui.ts design-system primitives: uniform uncolored bold headers, semantic glyph bullets (✓ ✗ ⚠ ○ ● •), aligned key/value blocks, canonical box-drawing tables, and standardized "No found" empty-list phrasing. diff --git a/src/cli/renderer/backup.ts b/src/cli/renderer/backup.ts index 05ca3e9..fc3594c 100644 --- a/src/cli/renderer/backup.ts +++ b/src/cli/renderer/backup.ts @@ -1,9 +1,9 @@ -import chalk from 'chalk'; import type { BackupResult, RestoreResult } from '../../backup/types.js'; +import { ui } from './ui.js'; /** Render header for backup/restore commands. */ export function renderBackupHeader(): void { - console.log(chalk.bold('\n Prisma AIRS — Backup & Restore\n')); + ui.header('Prisma AIRS — Backup & Restore'); } /** Render backup run summary. */ @@ -12,16 +12,16 @@ export function renderBackupSummary(results: BackupResult[], outputDir: string): const failed = results.filter((r) => r.status === 'failed'); if (ok.length > 0) { - console.log(chalk.bold(`\n Backed up ${ok.length} target(s) to ${outputDir}:\n`)); + ui.section(`Backed up ${ok.length} target(s) to ${outputDir}:`); for (const r of ok) { - console.log(` ${chalk.green('✓')} ${r.name} → ${chalk.dim(r.filename)}`); + ui.bullet(`${r.name} → ${r.filename}`, 'success'); } } if (failed.length > 0) { - console.log(chalk.bold(`\n Failed (${failed.length}):\n`)); + ui.section(`Failed (${failed.length}):`); for (const r of failed) { - console.log(` ${chalk.red('✗')} ${r.name}: ${r.error}`); + ui.bullet(`${r.name}: ${r.error}`, 'error'); } } @@ -33,22 +33,17 @@ export function renderRestoreSummary(results: RestoreResult[]): void { const groups = { created: 0, updated: 0, skipped: 0, failed: 0 }; for (const r of results) groups[r.action]++; - console.log(chalk.bold('\n Restore results:\n')); + ui.section('Restore results:'); for (const r of results) { - const icon = - r.action === 'failed' - ? chalk.red('✗') - : r.action === 'skipped' - ? chalk.yellow('○') - : chalk.green('✓'); + const kind = r.action === 'failed' ? 'error' : r.action === 'skipped' ? 'skip' : 'success'; const suffix = r.error ? `: ${r.error}` : ''; - console.log(` ${icon} ${r.name} — ${r.action}${suffix}`); + ui.bullet(`${r.name} — ${r.action}${suffix}`, kind); } const parts: string[] = []; if (groups.created) parts.push(`${groups.created} created`); if (groups.updated) parts.push(`${groups.updated} updated`); if (groups.skipped) parts.push(`${groups.skipped} skipped`); - if (groups.failed) parts.push(chalk.red(`${groups.failed} failed`)); + if (groups.failed) parts.push(`${groups.failed} failed`); console.log(`\n Total: ${parts.join(', ')}\n`); } diff --git a/src/cli/renderer/dlp.ts b/src/cli/renderer/dlp.ts index 61e1ba9..800230b 100644 --- a/src/cli/renderer/dlp.ts +++ b/src/cli/renderer/dlp.ts @@ -1,10 +1,12 @@ import chalk from 'chalk'; import { dump as yamlDump } from 'js-yaml'; import { formatOutput, type OutputFormat } from './common.js'; +import { ui } from './ui.js'; // biome-ignore lint/suspicious/noExplicitAny: renderer accepts arbitrary SDK payloads type Any = any; +/** Status → inline color (value coloring within composed list lines). */ function statusColor(status: string | undefined): string { switch (status) { case 'active': @@ -64,18 +66,18 @@ function emitList( return; } if (content.length === 0) { - console.log(chalk.dim(` No ${header.toLowerCase()} found.\n`)); + ui.emptyList(header.toLowerCase()); return; } if (fmt === 'pretty') { - console.log(chalk.bold(`\n ${header}:\n`)); + ui.section(`${header}:`); for (const item of content) console.log(prettyLine(item)); const meta = pageMeta(page, content.length); - console.log( - chalk.dim( - `\n page=${meta.number} size=${meta.size} returned=${meta.returned} total=${meta.total ?? '?'}\n`, - ), + console.log(); + ui.dim( + `page=${meta.number} size=${meta.size} returned=${meta.returned} total=${meta.total ?? '?'}`, ); + console.log(); return; } console.log(formatOutput(rows, columns, fmt)); @@ -110,12 +112,13 @@ function emitDetail( return; } if (fmt === 'pretty') { - console.log(chalk.bold(`\n ${title}:\n`)); - const w = Math.max(...fields.map((f) => f.label.length)); + ui.section(`${title}:`); + const pairs: Array<[string, unknown]> = []; for (const f of fields) { if (f.value === undefined || f.value === null || f.value === '') continue; - console.log(` ${f.label.padEnd(w)} ${f.value}`); + pairs.push([f.label, f.value]); } + ui.keyValue(pairs); console.log(); return; } @@ -135,7 +138,7 @@ function ackObject(verb: string, item: Any): Record { return out; } -function emitAck(verb: string, color: (s: string) => string, item: Any, fmt: OutputFormat): void { +function emitAck(verb: string, item: Any, fmt: OutputFormat): void { if (fmt === 'json' || fmt === 'yaml') { emitStructured(ackObject(verb, item), fmt); return; @@ -146,12 +149,12 @@ function emitAck(verb: string, color: (s: string) => string, item: Any, fmt: Out console.log(formatOutput([row], cols, fmt)); return; } - const ver = item?.version != null ? chalk.dim(` v${item.version}`) : ''; - console.log(` ${color(verb)} ${chalk.dim(item?.id ?? '')} ${item?.name ?? ''}${ver}`); + const ver = item?.version != null ? ` v${item.version}` : ''; + ui.success(`${verb} ${item?.id ?? ''} ${item?.name ?? ''}${ver}`); } -function emitIdAck(verb: string, color: (s: string) => string, id: string): void { - console.log(` ${color(verb)} ${chalk.dim(id)}`); +function emitIdAck(verb: string, id: string): void { + ui.success(`${verb} ${id}`); } // --------------------------------------------------------------------------- @@ -213,7 +216,7 @@ export const dlpFilteringProfiles = { ); }, renderReplaced(item: Any, fmt: OutputFormat) { - emitAck('replaced', chalk.green, item, fmt); + emitAck('replaced', item, fmt); }, }; @@ -281,16 +284,16 @@ export const dlpPatterns = { ); }, renderCreated(item: Any, fmt: OutputFormat) { - emitAck('created', chalk.green, item, fmt); + emitAck('created', item, fmt); }, renderReplaced(item: Any, fmt: OutputFormat) { - emitAck('replaced', chalk.green, item, fmt); + emitAck('replaced', item, fmt); }, renderPatched(item: Any, fmt: OutputFormat) { - emitAck('patched', chalk.green, item, fmt); + emitAck('patched', item, fmt); }, renderArchived(id: string) { - emitIdAck('archived', chalk.yellow, id); + emitIdAck('archived', id); }, }; @@ -345,13 +348,13 @@ export const dlpProfiles = { ); }, renderCreated(item: Any, fmt: OutputFormat) { - emitAck('created', chalk.green, item, fmt); + emitAck('created', item, fmt); }, renderReplaced(item: Any, fmt: OutputFormat) { - emitAck('replaced', chalk.green, item, fmt); + emitAck('replaced', item, fmt); }, renderPatched(item: Any, fmt: OutputFormat) { - emitAck('patched', chalk.green, item, fmt); + emitAck('patched', item, fmt); }, }; @@ -409,18 +412,18 @@ export const dlpDictionaries = { ); }, renderCreated(item: Any, fmt: OutputFormat) { - emitAck('created', chalk.green, item, fmt); + emitAck('created', item, fmt); }, renderReplaced(item: Any, fmt: OutputFormat) { - emitAck('replaced', chalk.green, item, fmt); + emitAck('replaced', item, fmt); }, renderPatched(item: Any, fmt: OutputFormat) { - emitAck('patched', chalk.green, item, fmt); + emitAck('patched', item, fmt); }, renderReplaced204Fallback(id: string) { - console.log(` ${chalk.green('replaced')} ${chalk.dim(id)} (state not echoed by region)`); + ui.success(`replaced ${id} (state not echoed by region)`); }, renderDeleted(id: string) { - emitIdAck('deleted', chalk.yellow, id); + emitIdAck('deleted', id); }, }; diff --git a/src/cli/renderer/eval.ts b/src/cli/renderer/eval.ts index aa2086a..d99fefb 100644 --- a/src/cli/renderer/eval.ts +++ b/src/cli/renderer/eval.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import type { EfficacyMetrics, TestResult } from '../../core/types.js'; +import { ui } from './ui.js'; export interface EvalOutput { profile: string; @@ -56,6 +57,7 @@ export function buildEvalOutput( } export function renderEvalTerminal(output: EvalOutput): void { + // Inline value coloring: coverage thresholds have no ui primitive. const coverageColor = output.metrics.coverage >= 0.9 ? chalk.green @@ -63,36 +65,37 @@ export function renderEvalTerminal(output: EvalOutput): void { ? chalk.yellow : chalk.red; - console.log(chalk.bold('\n Eval Results')); - console.log(chalk.dim(' ─────────────────────────')); - console.log(` Profile: ${chalk.white(output.profile)}`); - console.log(` Topic: ${chalk.white(output.topic)}`); - console.log(` Intent: ${chalk.white(output.intent)}`); + ui.header('Eval Results'); + ui.keyValue([ + ['Profile', output.profile], + ['Topic', output.topic], + ['Intent', output.intent], + ]); - console.log(chalk.bold('\n Metrics:')); - console.log(` Coverage: ${coverageColor(`${(output.metrics.coverage * 100).toFixed(1)}%`)}`); - console.log(` TPR: ${(output.metrics.tpr * 100).toFixed(1)}%`); - console.log(` TNR: ${(output.metrics.tnr * 100).toFixed(1)}%`); - console.log(` F1: ${output.metrics.f1.toFixed(3)}`); - console.log( - chalk.dim( - ` TP: ${output.metrics.tp} TN: ${output.metrics.tn} ` + - `FP: ${output.metrics.fp} FN: ${output.metrics.fn} ` + - `Total: ${output.metrics.total}`, - ), + ui.section('Metrics:'); + ui.keyValue([ + ['Coverage', coverageColor(`${(output.metrics.coverage * 100).toFixed(1)}%`)], + ['TPR', `${(output.metrics.tpr * 100).toFixed(1)}%`], + ['TNR', `${(output.metrics.tnr * 100).toFixed(1)}%`], + ['F1', output.metrics.f1.toFixed(3)], + ]); + ui.dim( + `TP: ${output.metrics.tp} TN: ${output.metrics.tn} ` + + `FP: ${output.metrics.fp} FN: ${output.metrics.fn} ` + + `Total: ${output.metrics.total}`, ); if (output.false_positives.length > 0) { - console.log(chalk.bold.yellow('\n False Positives:')); + ui.section('False Positives:'); for (const fp of output.false_positives) { - console.log(` ${chalk.yellow('●')} ${fp.prompt}`); + ui.bullet(fp.prompt, 'flag'); } } if (output.false_negatives.length > 0) { - console.log(chalk.bold.red('\n False Negatives:')); + ui.section('False Negatives:'); for (const fn of output.false_negatives) { - console.log(` ${chalk.red('●')} ${fn.prompt}`); + ui.bullet(fn.prompt, 'flag'); } } diff --git a/src/cli/renderer/modelsecurity.ts b/src/cli/renderer/modelsecurity.ts index 20554e7..5f0db65 100644 --- a/src/cli/renderer/modelsecurity.ts +++ b/src/cli/renderer/modelsecurity.ts @@ -10,11 +10,31 @@ import type { ModelSecurityViolation, } from '../../airs/types.js'; import { formatOutput, type OutputFormat } from './common.js'; +import { ui } from './ui.js'; /** Render the model security banner. */ export function renderModelSecurityHeader(): void { - console.log(chalk.bold.blue('\n Prisma AIRS — Model Security')); - console.log(chalk.dim(' ML model supply chain security\n')); + ui.header('Prisma AIRS — Model Security', 'ML model supply chain security'); +} + +/** State/result → inline color (value coloring within composed lines). */ +function stateColor(state: string): (s: string) => string { + switch (state) { + case 'ACTIVE': + case 'ALLOWED': + case 'ALLOWING': + case 'PASSED': + case 'SUCCESS': + return chalk.green; + case 'BLOCKED': + case 'BLOCKING': + case 'FAILED': + return chalk.red; + case 'DISABLED': + return chalk.dim; + default: + return chalk.yellow; + } } /** Render security group list. */ @@ -23,7 +43,7 @@ export function renderGroupList( format: OutputFormat = 'pretty', ): void { if (groups.length === 0) { - console.log(chalk.dim(' No security groups found.\n')); + ui.emptyList('security groups'); return; } if (format !== 'pretty') { @@ -47,11 +67,11 @@ export function renderGroupList( ); return; } - console.log(chalk.bold('\n Security Groups:\n')); + ui.section('Security Groups:'); for (const g of groups) { - console.log(` ${chalk.dim(g.uuid)}`); - const stateColor = g.state === 'ACTIVE' ? chalk.green : chalk.yellow; - console.log(` ${g.name} ${stateColor(g.state)} source: ${chalk.dim(g.sourceType)}`); + ui.dim(g.uuid); + const color = g.state === 'ACTIVE' ? chalk.green : chalk.yellow; + console.log(` ${g.name} ${color(g.state)} source: ${chalk.dim(g.sourceType)}`); } console.log(); } @@ -69,22 +89,24 @@ export function renderGroupDetail( console.log(yamlDump(group)); return; } - console.log(chalk.bold('\n Security Group Detail:\n')); - console.log(` UUID: ${chalk.dim(group.uuid)}`); - console.log(` Name: ${group.name}`); - console.log(` Description: ${group.description || chalk.dim('(none)')}`); - console.log(` Source Type: ${group.sourceType}`); - const stateColor = group.state === 'ACTIVE' ? chalk.green : chalk.yellow; - console.log(` State: ${stateColor(group.state)}`); - console.log(` Created: ${chalk.dim(group.createdAt)}`); - console.log(` Updated: ${chalk.dim(group.updatedAt)}`); + ui.section('Security Group Detail:'); + const color = group.state === 'ACTIVE' ? chalk.green : chalk.yellow; + ui.keyValue([ + ['UUID', group.uuid], + ['Name', group.name], + ['Description', group.description || chalk.dim('(none)')], + ['Source Type', group.sourceType], + ['State', color(group.state)], + ['Created', group.createdAt], + ['Updated', group.updatedAt], + ]); console.log(); } /** Render security rule list. */ export function renderRuleList(rules: ModelSecurityRule[], format: OutputFormat = 'pretty'): void { if (rules.length === 0) { - console.log(chalk.dim(' No security rules found.\n')); + ui.emptyList('security rules'); return; } if (format !== 'pretty') { @@ -110,9 +132,9 @@ export function renderRuleList(rules: ModelSecurityRule[], format: OutputFormat ); return; } - console.log(chalk.bold('\n Security Rules:\n')); + ui.section('Security Rules:'); for (const r of rules) { - console.log(` ${chalk.dim(r.uuid)}`); + ui.dim(r.uuid); console.log( ` ${r.name} type: ${chalk.dim(r.ruleType)} default: ${chalk.dim(r.defaultState)}`, ); @@ -124,32 +146,34 @@ export function renderRuleList(rules: ModelSecurityRule[], format: OutputFormat /** Render security rule detail. */ export function renderRuleDetail(rule: ModelSecurityRule): void { - console.log(chalk.bold('\n Security Rule Detail:\n')); - console.log(` UUID: ${chalk.dim(rule.uuid)}`); - console.log(` Name: ${rule.name}`); - console.log(` Description: ${rule.description}`); - console.log(` Rule Type: ${rule.ruleType}`); - console.log(` Default State: ${rule.defaultState}`); - console.log(` Sources: ${rule.compatibleSources.join(', ')}`); + ui.section('Security Rule Detail:'); + ui.keyValue([ + ['UUID', rule.uuid], + ['Name', rule.name], + ['Description', rule.description], + ['Rule Type', rule.ruleType], + ['Default State', rule.defaultState], + ['Sources', rule.compatibleSources.join(', ')], + ]); if (rule.remediation.description) { - console.log(chalk.bold('\n Remediation:')); - console.log(` ${rule.remediation.description}`); + ui.section('Remediation:'); + console.log(` ${rule.remediation.description}`); if (rule.remediation.steps.length > 0) { for (const step of rule.remediation.steps) { - console.log(` ${chalk.dim('•')} ${step}`); + ui.bullet(step, 'neutral'); } } if (rule.remediation.url) { - console.log(` ${chalk.dim(rule.remediation.url)}`); + ui.dim(rule.remediation.url); } } if (rule.editableFields.length > 0) { - console.log(chalk.bold('\n Editable Fields:')); + ui.section('Editable Fields:'); for (const f of rule.editableFields) { - console.log(` ${f.displayName} (${chalk.dim(f.attributeName)}): ${f.displayType}`); - if (f.description) console.log(` ${chalk.dim(f.description)}`); + console.log(` ${f.displayName} (${chalk.dim(f.attributeName)}): ${f.displayType}`); + if (f.description) console.log(` ${chalk.dim(f.description)}`); } } console.log(); @@ -158,44 +182,41 @@ export function renderRuleDetail(rule: ModelSecurityRule): void { /** Render rule instance list. */ export function renderRuleInstanceList(instances: ModelSecurityRuleInstance[]): void { if (instances.length === 0) { - console.log(chalk.dim(' No rule instances found.\n')); + ui.emptyList('rule instances'); return; } - console.log(chalk.bold('\n Rule Instances:\n')); + ui.section('Rule Instances:'); for (const ri of instances) { - const stateColor = - ri.state === 'BLOCKING' ? chalk.red : ri.state === 'ALLOWING' ? chalk.green : chalk.dim; const ruleName = (ri.rule as { name?: string })?.name ?? ri.securityRuleUuid; - console.log(` ${chalk.dim(ri.uuid)}`); - console.log(` ${ruleName} ${stateColor(ri.state)}`); + ui.dim(ri.uuid); + console.log(` ${ruleName} ${stateColor(ri.state)(ri.state)}`); } console.log(); } /** Render rule instance detail. */ export function renderRuleInstanceDetail(instance: ModelSecurityRuleInstance): void { - console.log(chalk.bold('\n Rule Instance Detail:\n')); - console.log(` UUID: ${chalk.dim(instance.uuid)}`); - console.log(` Group UUID: ${chalk.dim(instance.securityGroupUuid)}`); - console.log(` Rule UUID: ${chalk.dim(instance.securityRuleUuid)}`); - const stateColor = - instance.state === 'BLOCKING' - ? chalk.red - : instance.state === 'ALLOWING' - ? chalk.green - : chalk.dim; - console.log(` State: ${stateColor(instance.state)}`); + ui.section('Rule Instance Detail:'); + const pairs: Array<[string, unknown]> = [ + ['UUID', instance.uuid], + ['Group UUID', instance.securityGroupUuid], + ['Rule UUID', instance.securityRuleUuid], + ['State', stateColor(instance.state)(instance.state)], + ]; const ruleName = (instance.rule as { name?: string })?.name; - if (ruleName) console.log(` Rule Name: ${ruleName}`); - console.log(` Created: ${chalk.dim(instance.createdAt)}`); - console.log(` Updated: ${chalk.dim(instance.updatedAt)}`); + if (ruleName) pairs.push(['Rule Name', ruleName]); + pairs.push(['Created', instance.createdAt]); + pairs.push(['Updated', instance.updatedAt]); + ui.keyValue(pairs); if (Object.keys(instance.fieldValues).length > 0) { - console.log(chalk.bold('\n Field Values:')); - for (const [key, value] of Object.entries(instance.fieldValues)) { - const display = Array.isArray(value) ? value.join(', ') : String(value); - console.log(` ${key}: ${chalk.dim(display)}`); - } + ui.section('Field Values:'); + ui.keyValue( + Object.entries(instance.fieldValues).map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(', ') : String(value), + ]), + ); } console.log(); } @@ -210,7 +231,7 @@ export function renderMsScanList( format: OutputFormat = 'pretty', ): void { if (scans.length === 0) { - console.log(chalk.dim(' No scans found.\n')); + ui.emptyList('scans'); return; } if (format !== 'pretty') { @@ -240,17 +261,11 @@ export function renderMsScanList( ); return; } - console.log(chalk.bold('\n Model Security Scans:\n')); + ui.section('Model Security Scans:'); for (const s of scans) { - const outcomeColor = - s.evalOutcome === 'ALLOWED' - ? chalk.green - : s.evalOutcome === 'BLOCKED' - ? chalk.red - : chalk.yellow; - console.log(` ${chalk.dim(s.uuid)}`); + ui.dim(s.uuid); console.log( - ` ${outcomeColor(s.evalOutcome)} ${chalk.dim(s.scanOrigin)} ${chalk.dim(s.createdAt)}`, + ` ${stateColor(s.evalOutcome)(s.evalOutcome)} ${chalk.dim(s.scanOrigin)} ${chalk.dim(s.createdAt)}`, ); if (s.modelUri) console.log(` ${chalk.dim(s.modelUri)}`); if (s.evalSummary) { @@ -265,32 +280,28 @@ export function renderMsScanList( /** Render full scan detail. */ export function renderMsScanDetail(scan: ModelSecurityScan): void { - console.log(chalk.bold('\n Scan Detail:\n')); - console.log(` UUID: ${chalk.dim(scan.uuid)}`); - const outcomeColor = - scan.evalOutcome === 'ALLOWED' - ? chalk.green - : scan.evalOutcome === 'BLOCKED' - ? chalk.red - : chalk.yellow; - console.log(` Outcome: ${outcomeColor(scan.evalOutcome)}`); - if (scan.modelUri) console.log(` Model URI: ${scan.modelUri}`); - console.log(` Origin: ${scan.scanOrigin}`); - console.log(` Source: ${scan.sourceType}`); - console.log(` Group: ${scan.securityGroupName}`); - console.log(` Created: ${chalk.dim(scan.createdAt)}`); - console.log(` Updated: ${chalk.dim(scan.updatedAt)}`); + ui.section('Scan Detail:'); + const pairs: Array<[string, unknown]> = [ + ['UUID', scan.uuid], + ['Outcome', stateColor(scan.evalOutcome)(scan.evalOutcome)], + ]; + if (scan.modelUri) pairs.push(['Model URI', scan.modelUri]); + pairs.push(['Origin', scan.scanOrigin]); + pairs.push(['Source', scan.sourceType]); + pairs.push(['Group', scan.securityGroupName]); + pairs.push(['Created', scan.createdAt]); + pairs.push(['Updated', scan.updatedAt]); if (scan.evalSummary) { const { rulesPassed, rulesFailed, totalRules } = scan.evalSummary; - console.log( - ` Rules: ${chalk.green(`${rulesPassed} passed`)} ${chalk.red(`${rulesFailed} failed`)} / ${totalRules} total`, - ); + pairs.push([ + 'Rules', + `${chalk.green(`${rulesPassed} passed`)} ${chalk.red(`${rulesFailed} failed`)} / ${totalRules} total`, + ]); } + ui.keyValue(pairs); if (scan.labels.length > 0) { - console.log(chalk.bold('\n Labels:')); - for (const l of scan.labels) { - console.log(` ${l.key}: ${chalk.dim(l.value)}`); - } + ui.section('Labels:'); + ui.keyValue(scan.labels.map((l) => [l.key, l.value])); } console.log(); } @@ -302,35 +313,31 @@ export function renderMsScanDetail(scan: ModelSecurityScan): void { /** Render a list of evaluations. */ export function renderEvaluationList(evaluations: ModelSecurityEvaluation[]): void { if (evaluations.length === 0) { - console.log(chalk.dim(' No evaluations found.\n')); + ui.emptyList('evaluations'); return; } - console.log(chalk.bold('\n Rule Evaluations:\n')); + ui.section('Rule Evaluations:'); for (const e of evaluations) { - const color = - e.result === 'PASSED' ? chalk.green : e.result === 'FAILED' ? chalk.red : chalk.yellow; - console.log(` ${chalk.dim(e.uuid)}`); - console.log(` ${e.ruleName} ${color(e.result)} ${chalk.dim(e.ruleInstanceState)}`); + ui.dim(e.uuid); + console.log( + ` ${e.ruleName} ${stateColor(e.result)(e.result)} ${chalk.dim(e.ruleInstanceState)}`, + ); } console.log(); } /** Render a single evaluation detail. */ export function renderEvaluationDetail(evaluation: ModelSecurityEvaluation): void { - console.log(chalk.bold('\n Evaluation Detail:\n')); - console.log(` UUID: ${chalk.dim(evaluation.uuid)}`); - console.log(` Rule: ${evaluation.ruleName}`); - console.log(` Description: ${chalk.dim(evaluation.ruleDescription)}`); - console.log(` Instance UUID: ${chalk.dim(evaluation.ruleInstanceUuid)}`); - console.log(` Instance State: ${evaluation.ruleInstanceState}`); - const color = - evaluation.result === 'PASSED' - ? chalk.green - : evaluation.result === 'FAILED' - ? chalk.red - : chalk.yellow; - console.log(` Result: ${color(evaluation.result)}`); - console.log(` Violations: ${evaluation.violationCount}`); + ui.section('Evaluation Detail:'); + ui.keyValue([ + ['UUID', evaluation.uuid], + ['Rule', evaluation.ruleName], + ['Description', evaluation.ruleDescription], + ['Instance UUID', evaluation.ruleInstanceUuid], + ['Instance State', evaluation.ruleInstanceState], + ['Result', stateColor(evaluation.result)(evaluation.result)], + ['Violations', evaluation.violationCount], + ]); console.log(); } @@ -341,12 +348,12 @@ export function renderEvaluationDetail(evaluation: ModelSecurityEvaluation): voi /** Render a list of violations. */ export function renderViolationList(violations: ModelSecurityViolation[]): void { if (violations.length === 0) { - console.log(chalk.dim(' No violations found.\n')); + ui.emptyList('violations'); return; } - console.log(chalk.bold('\n Violations:\n')); + ui.section('Violations:'); for (const v of violations) { - console.log(` ${chalk.dim(v.uuid)}`); + ui.dim(v.uuid); console.log(` ${chalk.red(v.ruleName)} ${chalk.dim(v.file)}`); console.log(` ${v.description}`); console.log(` Threat: ${chalk.dim(v.threat)}`); @@ -356,14 +363,16 @@ export function renderViolationList(violations: ModelSecurityViolation[]): void /** Render a single violation detail. */ export function renderViolationDetail(violation: ModelSecurityViolation): void { - console.log(chalk.bold('\n Violation Detail:\n')); - console.log(` UUID: ${chalk.dim(violation.uuid)}`); - console.log(` Rule: ${chalk.red(violation.ruleName)}`); - console.log(` Description: ${violation.ruleDescription}`); - console.log(` State: ${violation.ruleInstanceState}`); - console.log(` File: ${violation.file}`); - console.log(` Threat: ${violation.threat}`); - console.log(` Detail: ${violation.description}`); + ui.section('Violation Detail:'); + ui.keyValue([ + ['UUID', violation.uuid], + ['Rule', chalk.red(violation.ruleName)], + ['Description', violation.ruleDescription], + ['State', violation.ruleInstanceState], + ['File', violation.file], + ['Threat', violation.threat], + ['Detail', violation.description], + ]); console.log(); } @@ -374,10 +383,10 @@ export function renderViolationDetail(violation: ModelSecurityViolation): void { /** Render a list of scanned files. */ export function renderFileList(files: ModelSecurityFile[]): void { if (files.length === 0) { - console.log(chalk.dim(' No files found.\n')); + ui.emptyList('files'); return; } - console.log(chalk.bold('\n Scanned Files:\n')); + ui.section('Scanned Files:'); for (const f of files) { const color = f.result === 'SUCCESS' ? chalk.green : f.result === 'SKIPPED' ? chalk.yellow : chalk.red; @@ -394,12 +403,12 @@ export function renderFileList(files: ModelSecurityFile[]): void { /** Render label keys. */ export function renderLabelKeys(keys: string[]): void { if (keys.length === 0) { - console.log(chalk.dim(' No label keys found.\n')); + ui.emptyList('label keys'); return; } - console.log(chalk.bold('\n Label Keys:\n')); + ui.section('Label Keys:'); for (const k of keys) { - console.log(` ${k}`); + console.log(` ${k}`); } console.log(); } @@ -407,12 +416,12 @@ export function renderLabelKeys(keys: string[]): void { /** Render label values for a key. */ export function renderLabelValues(key: string, values: string[]): void { if (values.length === 0) { - console.log(chalk.dim(` No values for key "${key}".\n`)); + ui.emptyList(`values for key "${key}"`); return; } - console.log(chalk.bold(`\n Label Values for "${key}":\n`)); + ui.section(`Label Values for "${key}":`); for (const v of values) { - console.log(` ${v}`); + console.log(` ${v}`); } console.log(); } diff --git a/src/cli/renderer/redteam.ts b/src/cli/renderer/redteam.ts index 0191d1b..b57bb97 100644 --- a/src/cli/renderer/redteam.ts +++ b/src/cli/renderer/redteam.ts @@ -1,16 +1,16 @@ import chalk from 'chalk'; import { dump as yamlDump } from 'js-yaml'; import { formatOutput, type OutputFormat } from './common.js'; +import { ui } from './ui.js'; /** Render the red team banner. */ export function renderRedteamHeader(): void { - console.log(chalk.bold.red('\n Prisma AIRS — AI Red Team')); - console.log(chalk.dim(' Adversarial scan operations\n')); + ui.header('Prisma AIRS — AI Red Team', 'Adversarial scan operations'); } type ChalkFn = (text: string) => string; -/** Severity → chalk color mapping. */ +/** Severity → chalk color mapping (inline value coloring within composed lines). */ function severityColor(severity: string): ChalkFn { switch (severity.toUpperCase()) { case 'CRITICAL': @@ -26,7 +26,7 @@ function severityColor(severity: string): ChalkFn { } } -/** Status → chalk color mapping. */ +/** Status → chalk color mapping (inline value coloring within composed lines). */ function statusColor(status: string): ChalkFn { switch (status) { case 'COMPLETED': @@ -46,6 +46,11 @@ function statusColor(status: string): ChalkFn { } } +/** Colorize an active/inactive state word. */ +function activeState(active: boolean): string { + return statusColor(active ? 'COMPLETED' : 'FAILED')(active ? 'active' : 'inactive'); +} + /** Render a scan's status summary. */ export function renderScanStatus(job: { uuid: string; @@ -58,17 +63,20 @@ export function renderScanStatus(job: { completed?: number | null; total?: number | null; }): void { - console.log(chalk.bold(' Scan Status:')); - console.log(` ID: ${chalk.dim(job.uuid)}`); - console.log(` Name: ${job.name}`); - console.log(` Type: ${job.jobType}`); - if (job.targetName) console.log(` Target: ${job.targetName}`); - console.log(` Status: ${statusColor(job.status)(job.status)}`); + ui.section('Scan Status:'); + const pairs: Array<[string, unknown]> = [ + ['ID', job.uuid], + ['Name', job.name], + ['Type', job.jobType], + ]; + if (job.targetName) pairs.push(['Target', job.targetName]); + pairs.push(['Status', statusColor(job.status)(job.status)]); if (job.total != null && job.completed != null) { - console.log(` Progress: ${job.completed}/${job.total}`); + pairs.push(['Progress', `${job.completed}/${job.total}`]); } - if (job.score != null) console.log(` Score: ${job.score}`); - if (job.asr != null) console.log(` ASR: ${job.asr.toFixed(1)}%`); + if (job.score != null) pairs.push(['Score', job.score]); + if (job.asr != null) pairs.push(['ASR', `${job.asr.toFixed(1)}%`]); + ui.keyValue(pairs); console.log(); } @@ -85,7 +93,7 @@ export function renderScanList( format: OutputFormat = 'pretty', ): void { if (jobs.length === 0) { - console.log(chalk.dim(' No scans found.\n')); + ui.emptyList('scans'); return; } if (format !== 'pretty') { @@ -113,9 +121,9 @@ export function renderScanList( ); return; } - console.log(chalk.bold('\n Recent Scans:\n')); + ui.section('Recent Scans:'); for (const job of jobs) { - console.log(` ${chalk.dim(job.uuid)}`); + ui.dim(job.uuid); console.log( ` ${job.name} ${statusColor(job.status)(job.status)} ${job.jobType}${job.score != null ? ` score: ${job.score}` : ''}`, ); @@ -139,12 +147,14 @@ export function renderStaticReport(report: { total: number; }>; }): void { - console.log(chalk.bold('\n Static Scan Report:')); - if (report.score != null) console.log(` Score: ${report.score}`); - if (report.asr != null) console.log(` ASR: ${report.asr.toFixed(1)}%`); + ui.section('Static Scan Report:'); + const pairs: Array<[string, unknown]> = []; + if (report.score != null) pairs.push(['Score', report.score]); + if (report.asr != null) pairs.push(['ASR', `${report.asr.toFixed(1)}%`]); + if (pairs.length > 0) ui.keyValue(pairs); if (report.severityBreakdown.length > 0) { - console.log(chalk.bold('\n Severity Breakdown:')); + ui.section('Severity Breakdown:'); for (const s of report.severityBreakdown) { const color = severityColor(s.severity); console.log( @@ -154,16 +164,23 @@ export function renderStaticReport(report: { } if (report.categories.length > 0) { - console.log(chalk.bold('\n Categories:')); - for (const c of report.categories) { - console.log( - ` ${c.displayName.padEnd(30)} ASR: ${c.asr.toFixed(1)}% (${c.successful}/${c.total})`, - ); - } + ui.section('Categories:'); + ui.table( + [ + { key: 'category', label: 'Category' }, + { key: 'asr', label: 'ASR' }, + { key: 'hits', label: 'Bypassed/Total' }, + ], + report.categories.map((c) => ({ + category: c.displayName, + asr: `${c.asr.toFixed(1)}%`, + hits: `${c.successful}/${c.total}`, + })), + ); } if (report.reportSummary) { - console.log(chalk.bold('\n Summary:')); + ui.section('Summary:'); console.log(` ${report.reportSummary}`); } console.log(); @@ -179,19 +196,22 @@ export function renderDynamicReport(report: { totalThreats?: number; reportSummary?: string | null; }): void { - console.log(chalk.bold('\n Dynamic Scan Report:')); - if (report.score != null) console.log(` Score: ${report.score}`); - if (report.asr != null) console.log(` ASR: ${(report.asr * 100).toFixed(1)}%`); + ui.section('Dynamic Scan Report:'); + const pairs: Array<[string, unknown]> = []; + if (report.score != null) pairs.push(['Score', report.score]); + if (report.asr != null) pairs.push(['ASR', `${(report.asr * 100).toFixed(1)}%`]); if (report.totalGoals != null || report.goalsAchieved != null) { - console.log( - ` Goals: ${report.goalsAchieved ?? 0} achieved / ${report.totalGoals ?? 0} total`, - ); + pairs.push([ + 'Goals', + `${report.goalsAchieved ?? 0} achieved / ${report.totalGoals ?? 0} total`, + ]); } - if (report.totalStreams != null) console.log(` Streams: ${report.totalStreams}`); - if (report.totalThreats != null) console.log(` Threats: ${report.totalThreats}`); + if (report.totalStreams != null) pairs.push(['Streams', report.totalStreams]); + if (report.totalThreats != null) pairs.push(['Threats', report.totalThreats]); + if (pairs.length > 0) ui.keyValue(pairs); if (report.reportSummary) { - console.log(chalk.bold('\n Summary:')); + ui.section('Summary:'); console.log(` ${report.reportSummary}`); } console.log(); @@ -211,18 +231,28 @@ export function renderCustomReport(report: { threatRate: number; }>; }): void { - console.log(chalk.bold('\n Custom Attack Report:')); - console.log(` Score: ${report.score}`); - console.log(` ASR: ${report.asr.toFixed(1)}%`); - console.log(` Attacks: ${report.totalAttacks} Threats: ${report.totalThreats}`); + ui.section('Custom Attack Report:'); + ui.keyValue([ + ['Score', report.score], + ['ASR', `${report.asr.toFixed(1)}%`], + ['Attacks', report.totalAttacks], + ['Threats', report.totalThreats], + ]); if (report.promptSets.length > 0) { - console.log(chalk.bold('\n Prompt Sets:')); - for (const ps of report.promptSets) { - console.log( - ` ${ps.promptSetName.padEnd(40)} ${ps.totalThreats}/${ps.totalPrompts} threats (${ps.threatRate.toFixed(1)}%)`, - ); - } + ui.section('Prompt Sets:'); + ui.table( + [ + { key: 'promptSet', label: 'Prompt Set' }, + { key: 'threats', label: 'Threats' }, + { key: 'threatRate', label: 'Threat Rate' }, + ], + report.promptSets.map((ps) => ({ + promptSet: ps.promptSetName, + threats: `${ps.totalThreats}/${ps.totalPrompts}`, + threatRate: `${ps.threatRate.toFixed(1)}%`, + })), + ); } console.log(); } @@ -240,11 +270,11 @@ export function renderAttackList( options?: { footnote?: string }, ): void { if (attacks.length === 0) { - console.log(chalk.dim(' No attacks found.\n')); - if (options?.footnote) console.log(chalk.dim(` ${options.footnote}\n`)); + ui.emptyList('attacks'); + if (options?.footnote) ui.dim(options.footnote); return; } - console.log(chalk.bold('\n Attacks:\n')); + ui.section('Attacks:'); for (const a of attacks) { const sev = a.severity ? severityColor(a.severity)(a.severity.padEnd(10)) @@ -253,7 +283,7 @@ export function renderAttackList( const label = a.subCategoryDisplayName ?? a.subCategory ?? '—'; console.log(` ${sev} ${result} ${label}${a.category ? chalk.dim(` [${a.category}]`) : ''}`); } - if (options?.footnote) console.log(chalk.dim(` ${options.footnote}`)); + if (options?.footnote) ui.dim(options.footnote); console.log(); } @@ -286,10 +316,10 @@ export function renderCustomAttackList( }>, ): void { if (attacks.length === 0) { - console.log(chalk.dim(' No custom attacks found.\n')); + ui.emptyList('custom attacks'); return; } - console.log(chalk.bold('\n Custom Attacks:\n')); + ui.section('Custom Attacks:'); for (const a of attacks) { const result = a.threat ? chalk.red('THREAT') : chalk.green('SAFE'); const prompt = a.promptText.length > 80 ? `${a.promptText.substring(0, 77)}...` : a.promptText; @@ -312,7 +342,7 @@ export function renderTargetList( format: OutputFormat = 'pretty', ): void { if (targets.length === 0) { - console.log(chalk.dim(' No targets found.\n')); + ui.emptyList('targets'); return; } if (format !== 'pretty') { @@ -336,11 +366,11 @@ export function renderTargetList( ); return; } - console.log(chalk.bold('\n Targets:\n')); + ui.section('Targets:'); for (const t of targets) { - console.log(` ${chalk.dim(t.uuid)}`); + ui.dim(t.uuid); console.log( - ` ${t.name} ${statusColor(t.active ? 'COMPLETED' : 'FAILED')(t.active ? 'active' : 'inactive')}${t.targetType ? ` type: ${t.targetType}` : ''}`, + ` ${t.name} ${activeState(t.active)}${t.targetType ? ` type: ${t.targetType}` : ''}`, ); } console.log(); @@ -356,10 +386,10 @@ export function renderCategories( }>, ): void { if (categories.length === 0) { - console.log(chalk.dim(' No categories found.\n')); + ui.emptyList('categories'); return; } - console.log(chalk.bold('\n Attack Categories:\n')); + ui.section('Attack Categories:'); for (const c of categories) { console.log( ` ${chalk.bold(c.displayName)} ${chalk.cyan(`(${c.id})`)}${c.description ? chalk.dim(` — ${c.description}`) : ''}`, @@ -379,7 +409,7 @@ export function renderPromptSetList( format: OutputFormat = 'pretty', ): void { if (promptSets.length === 0) { - console.log(chalk.dim(' No prompt sets found.\n')); + ui.emptyList('prompt sets'); return; } if (format !== 'pretty') { @@ -401,12 +431,10 @@ export function renderPromptSetList( ); return; } - console.log(chalk.bold('\n Prompt Sets:\n')); + ui.section('Prompt Sets:'); for (const ps of promptSets) { - console.log(` ${chalk.dim(ps.uuid)}`); - console.log( - ` ${ps.name} ${statusColor(ps.active ? 'COMPLETED' : 'FAILED')(ps.active ? 'active' : 'inactive')}`, - ); + ui.dim(ps.uuid); + console.log(` ${ps.name} ${activeState(ps.active)}`); } console.log(); } @@ -420,6 +448,16 @@ function formatDetailValue(value: unknown, indent: string): string { return json.split('\n').join(`\n${indent}`); } +/** Render a key/value object block as an aligned keyValue list with JSON-expanded nested values. */ +function keyValueFromObject(obj: Record, skipNullish = false): void { + const pairs: Array<[string, unknown]> = []; + for (const [k, v] of Object.entries(obj)) { + if (skipNullish && v == null) continue; + pairs.push([k, formatDetailValue(v, ' ')]); + } + if (pairs.length > 0) ui.keyValue(pairs); +} + /** Render target detail. */ export function renderTargetDetail( target: { @@ -442,30 +480,25 @@ export function renderTargetDetail( } return; } - console.log(chalk.bold('\n Target Detail:\n')); - console.log(` UUID: ${chalk.dim(target.uuid)}`); - console.log(` Name: ${target.name}`); - console.log( - ` Status: ${statusColor(target.active ? 'COMPLETED' : 'FAILED')(target.active ? 'active' : 'inactive')}`, - ); - if (target.targetType) console.log(` Type: ${target.targetType}`); + ui.section('Target Detail:'); + const pairs: Array<[string, unknown]> = [ + ['UUID', target.uuid], + ['Name', target.name], + ['Status', activeState(target.active)], + ]; + if (target.targetType) pairs.push(['Type', target.targetType]); + ui.keyValue(pairs); if (target.connectionParams) { - console.log(chalk.bold('\n Connection:')); - for (const [k, v] of Object.entries(target.connectionParams)) { - console.log(` ${k}: ${chalk.dim(formatDetailValue(v, ' '))}`); - } + ui.section('Connection:'); + keyValueFromObject(target.connectionParams); } if (target.background) { - console.log(chalk.bold('\n Background:')); - for (const [k, v] of Object.entries(target.background)) { - if (v != null) console.log(` ${k}: ${chalk.dim(formatDetailValue(v, ' '))}`); - } + ui.section('Background:'); + keyValueFromObject(target.background, true); } if (target.metadata) { - console.log(chalk.bold('\n Metadata:')); - for (const [k, v] of Object.entries(target.metadata)) { - if (v != null) console.log(` ${k}: ${chalk.dim(formatDetailValue(v, ' '))}`); - } + ui.section('Metadata:'); + keyValueFromObject(target.metadata, true); } console.log(); } @@ -497,16 +530,17 @@ export function renderPromptSetDetail( } return; } - console.log(chalk.bold('\n Prompt Set Detail:\n')); - console.log(` UUID: ${chalk.dim(ps.uuid)}`); - console.log(` Name: ${ps.name}`); - console.log( - ` Status: ${statusColor(ps.active ? 'COMPLETED' : 'FAILED')(ps.active ? 'active' : 'inactive')}`, - ); - console.log(` Archived: ${ps.archive ? 'yes' : 'no'}`); - if (ps.description) console.log(` Description: ${ps.description}`); - if (ps.createdAt) console.log(` Created: ${chalk.dim(ps.createdAt)}`); - if (ps.updatedAt) console.log(` Updated: ${chalk.dim(ps.updatedAt)}`); + ui.section('Prompt Set Detail:'); + const pairs: Array<[string, unknown]> = [ + ['UUID', ps.uuid], + ['Name', ps.name], + ['Status', activeState(ps.active)], + ['Archived', ps.archive ? 'yes' : 'no'], + ]; + if (ps.description) pairs.push(['Description', ps.description]); + if (ps.createdAt) pairs.push(['Created', ps.createdAt]); + if (ps.updatedAt) pairs.push(['Updated', ps.updatedAt]); + ui.keyValue(pairs); console.log(); } @@ -516,18 +550,20 @@ export function renderVersionInfo(info: { version: number; stats: { total: number; active: number; inactive: number }; }): void { - console.log(chalk.bold('\n Version Info:\n')); - console.log(` Version: ${info.version}`); - console.log(` Total: ${info.stats.total}`); - console.log(` Active: ${chalk.green(String(info.stats.active))}`); - console.log(` Inactive: ${chalk.dim(String(info.stats.inactive))}`); + ui.section('Version Info:'); + ui.keyValue([ + ['Version', info.version], + ['Total', info.stats.total], + ['Active', info.stats.active], + ['Inactive', info.stats.inactive], + ]); console.log(); } /** Render a placeholder when the version-info endpoint is unavailable (upstream 500). */ export function renderVersionInfoUnavailable(): void { - console.log(chalk.bold('\n Version Info:\n')); - console.log(chalk.dim(' unavailable (version-info endpoint returned an error)')); + ui.section('Version Info:'); + ui.dim('unavailable (version-info endpoint returned an error)'); console.log(); } @@ -541,10 +577,10 @@ export function renderPromptList( }>, ): void { if (prompts.length === 0) { - console.log(chalk.dim(' No prompts found.\n')); + ui.emptyList('prompts'); return; } - console.log(chalk.bold('\n Prompts:\n')); + ui.section('Prompts:'); for (const p of prompts) { const status = p.active ? chalk.green('active') : chalk.dim('inactive'); const text = p.prompt.length > 80 ? `${p.prompt.substring(0, 77)}...` : p.prompt; @@ -563,12 +599,15 @@ export function renderPromptDetail(p: { active: boolean; promptSetId: string; }): void { - console.log(chalk.bold('\n Prompt Detail:\n')); - console.log(` UUID: ${chalk.dim(p.uuid)}`); - console.log(` Set UUID: ${chalk.dim(p.promptSetId)}`); - console.log(` Status: ${p.active ? chalk.green('active') : chalk.dim('inactive')}`); - console.log(` Prompt: ${p.prompt}`); - if (p.goal) console.log(` Goal: ${p.goal}`); + ui.section('Prompt Detail:'); + const pairs: Array<[string, unknown]> = [ + ['UUID', p.uuid], + ['Set UUID', p.promptSetId], + ['Status', p.active ? chalk.green('active') : chalk.dim('inactive')], + ['Prompt', p.prompt], + ]; + if (p.goal) pairs.push(['Goal', p.goal]); + ui.keyValue(pairs); console.log(); } @@ -586,12 +625,12 @@ export function renderPropertyNames(names: string[], format: OutputFormat = 'pre return; } if (names.length === 0) { - console.log(chalk.dim(' No property names found.\n')); + ui.emptyList('property names'); return; } - console.log(chalk.bold('\n Property Names:\n')); + ui.section('Property Names:'); for (const n of names) { - console.log(` ${chalk.dim('•')} ${n}`); + ui.bullet(n, 'neutral'); } console.log(); } @@ -602,19 +641,22 @@ export function renderAuthValidation(result: { tokenPreview?: string; expiresIn?: number; }): void { - console.log(chalk.bold('\n Auth Validation:\n')); - console.log(` Validated: ${result.validated ? chalk.green('yes') : chalk.red('no')}`); - if (result.tokenPreview) console.log(` Token: ${chalk.dim(result.tokenPreview)}`); - if (result.expiresIn != null) console.log(` Expires In: ${result.expiresIn}s`); + ui.section('Auth Validation:'); + const pairs: Array<[string, unknown]> = [ + ['Validated', result.validated ? chalk.green('yes') : chalk.red('no')], + ]; + if (result.tokenPreview) pairs.push(['Token', result.tokenPreview]); + if (result.expiresIn != null) pairs.push(['Expires In', `${result.expiresIn}s`]); + ui.keyValue(pairs); console.log(); } /** Render target templates keyed by provider. */ export function renderTargetTemplates(templates: Record): void { - console.log(chalk.bold('\n Target Templates:\n')); + ui.section('Target Templates:'); for (const [provider, config] of Object.entries(templates)) { - console.log(` ${chalk.bold(provider)}`); - console.log(` ${chalk.dim(JSON.stringify(config, null, 2).replace(/\n/g, '\n '))}`); + ui.section(provider); + ui.dim(JSON.stringify(config, null, 2).replace(/\n/g, '\n ')); console.log(); } } @@ -625,19 +667,20 @@ export function renderEulaStatus(status: { acceptedAt?: string; acceptedByUserId?: string; }): void { - console.log(chalk.bold('\n EULA Status:\n')); - console.log(` Accepted: ${status.isAccepted ? chalk.green('yes') : chalk.red('no')}`); - if (status.acceptedAt) console.log(` Accepted At: ${chalk.dim(status.acceptedAt)}`); - if (status.acceptedByUserId) { - console.log(` Accepted By: ${chalk.dim(status.acceptedByUserId)}`); - } + ui.section('EULA Status:'); + const pairs: Array<[string, unknown]> = [ + ['Accepted', status.isAccepted ? chalk.green('yes') : chalk.red('no')], + ]; + if (status.acceptedAt) pairs.push(['Accepted At', status.acceptedAt]); + if (status.acceptedByUserId) pairs.push(['Accepted By', status.acceptedByUserId]); + ui.keyValue(pairs); console.log(); } /** Render EULA content. */ export function renderEulaContent(content: { content: string }): void { - console.log(chalk.bold('\n EULA Content:\n')); - console.log(` ${content.content}\n`); + ui.section('EULA Content:'); + console.log(` ${content.content}\n`); } /** Render property values for a single property name. */ @@ -654,13 +697,13 @@ export function renderPropertyValues( return; } if (payload.values.length === 0) { - console.log(chalk.dim(' No property values found.\n')); + ui.emptyList('property values'); return; } - console.log(chalk.bold('\n Property Values:\n')); - console.log(` ${chalk.dim('Property:')} ${payload.name}`); + ui.section('Property Values:'); + ui.keyValue([['Property', payload.name]]); for (const v of payload.values) { - console.log(` ${chalk.dim('•')} ${v}`); + ui.bullet(v, 'neutral'); } console.log(); } @@ -672,13 +715,14 @@ export function renderInstanceResponse(resp: { appId?: string; isSuccess?: boolean; }): void { - console.log(chalk.bold('\n Instance:\n')); - console.log(` TSG ID: ${resp.tsgId}`); - if (resp.tenantId) console.log(` Tenant ID: ${resp.tenantId}`); - if (resp.appId) console.log(` App ID: ${resp.appId}`); + ui.section('Instance:'); + const pairs: Array<[string, unknown]> = [['TSG ID', resp.tsgId]]; + if (resp.tenantId) pairs.push(['Tenant ID', resp.tenantId]); + if (resp.appId) pairs.push(['App ID', resp.appId]); if (resp.isSuccess != null) { - console.log(` Success: ${resp.isSuccess ? chalk.green('yes') : chalk.red('no')}`); + pairs.push(['Success', resp.isSuccess ? chalk.green('yes') : chalk.red('no')]); } + ui.keyValue(pairs); console.log(); } @@ -689,18 +733,22 @@ export function renderInstanceDetail(inst: { appId: string; region: string; }): void { - console.log(chalk.bold('\n Instance Detail:\n')); - console.log(` TSG ID: ${inst.tsgId}`); - console.log(` Tenant ID: ${inst.tenantId}`); - console.log(` App ID: ${inst.appId}`); - console.log(` Region: ${inst.region}`); + ui.section('Instance Detail:'); + ui.keyValue([ + ['TSG ID', inst.tsgId], + ['Tenant ID', inst.tenantId], + ['App ID', inst.appId], + ['Region', inst.region], + ]); console.log(); } /** Render registry credentials. */ export function renderRegistryCredentials(creds: { token: string; expiry: string }): void { - console.log(chalk.bold('\n Registry Credentials:\n')); - console.log(` Token: ${chalk.dim(creds.token.substring(0, 20))}...`); - console.log(` Expiry: ${creds.expiry}`); + ui.section('Registry Credentials:'); + ui.keyValue([ + ['Token', `${creds.token.substring(0, 20)}...`], + ['Expiry', creds.expiry], + ]); console.log(); } diff --git a/src/cli/renderer/runtime.ts b/src/cli/renderer/runtime.ts index 7dbfbae..ce8f548 100644 --- a/src/cli/renderer/runtime.ts +++ b/src/cli/renderer/runtime.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import { formatOutput, type OutputFormat } from './common.js'; +import { ui } from './ui.js'; /** Render polling progress inline. */ export function renderScanProgress(job: { @@ -25,10 +26,8 @@ export function renderTestsComposed( regressionTier: number, total: number, ): void { - console.log( - chalk.dim( - ` Tests: ${generated} generated, ${carriedFailures} carried failures, ${regressionTier} regression, ${total} total`, - ), + ui.dim( + `Tests: ${generated} generated, ${carriedFailures} carried failures, ${regressionTier} regression, ${total} total`, ); } @@ -38,16 +37,16 @@ export function renderTestsAccumulated( totalCount: number, droppedCount: number, ): void { - let msg = ` Tests: ${newCount} new, ${totalCount} total (accumulated)`; + let msg = `Tests: ${newCount} new, ${totalCount} total (accumulated)`; if (droppedCount > 0) { - msg += chalk.yellow(` (${droppedCount} dropped by cap)`); + msg += ` (${droppedCount} dropped by cap)`; } - console.log(chalk.dim(msg)); + ui.dim(msg); } type ChalkFn = (text: string) => string; -/** Status → chalk color mapping. */ +/** Status → chalk color mapping (inline value coloring within composed lines). */ function statusColor(status: string): ChalkFn { switch (status) { case 'COMPLETED': @@ -73,8 +72,7 @@ function statusColor(status: string): ChalkFn { /** Render the runtime config banner. */ export function renderRuntimeConfigHeader(): void { - console.log(chalk.bold.cyan('\n Prisma AIRS — Runtime Configuration')); - console.log(chalk.dim(' Security profile and topic management\n')); + ui.header('Prisma AIRS — Runtime Configuration', 'Security profile and topic management'); } /** Render security profile list. */ @@ -89,7 +87,7 @@ export function renderProfileList( format: OutputFormat = 'pretty', ): void { if (profiles.length === 0) { - console.log(chalk.dim(' No profiles found.\n')); + ui.emptyList('profiles'); return; } @@ -110,9 +108,9 @@ export function renderProfileList( return; } - console.log(chalk.bold('\n Security Profiles:\n')); + ui.section('Security Profiles:'); for (const p of profiles) { - console.log(` ${chalk.dim(p.profileId)}`); + ui.dim(p.profileId); const status = p.active ? chalk.green('active') : chalk.yellow('inactive'); const rev = p.revision != null ? chalk.dim(` rev:${p.revision}`) : ''; console.log(` ${p.profileName} ${status}${rev}`); @@ -131,22 +129,21 @@ export function renderProfileDetail(profile: { lastModifiedTs?: string; policy?: Record; }): void { - console.log(chalk.bold('\n Profile Detail:\n')); - console.log(` ID: ${chalk.dim(profile.profileId)}`); - console.log(` Name: ${profile.profileName}`); - console.log(` Status: ${profile.active ? chalk.green('active') : chalk.yellow('inactive')}`); - if (profile.revision != null) console.log(` Revision: ${profile.revision}`); - if (profile.createdBy) console.log(` Created: ${chalk.dim(profile.createdBy)}`); - if (profile.updatedBy) console.log(` Updated: ${chalk.dim(profile.updatedBy)}`); - if (profile.lastModifiedTs) console.log(` Modified: ${chalk.dim(profile.lastModifiedTs)}`); + ui.section('Profile Detail:'); + const pairs: Array<[string, unknown]> = [ + ['ID', profile.profileId], + ['Name', profile.profileName], + ['Status', profile.active ? chalk.green('active') : chalk.yellow('inactive')], + ]; + if (profile.revision != null) pairs.push(['Revision', profile.revision]); + if (profile.createdBy) pairs.push(['Created', profile.createdBy]); + if (profile.updatedBy) pairs.push(['Updated', profile.updatedBy]); + if (profile.lastModifiedTs) pairs.push(['Modified', profile.lastModifiedTs]); if (profile.policy) { const policyJson = JSON.stringify(profile.policy, null, 2); - const indented = policyJson - .split('\n') - .map((line, i) => (i === 0 ? line : ` ${line}`)) - .join('\n'); - console.log(` Policy: ${chalk.dim(indented)}`); + pairs.push(['Policy', policyJson.split('\n').join('\n ')]); } + ui.keyValue(pairs); console.log(); } @@ -178,25 +175,25 @@ export function renderCleanupPreview( return; } - console.log(chalk.bold('\n Duplicate Profiles:\n')); - - const nameWidth = Math.max(7, ...groups.map((g) => g.name.length)); - const header = ` ${'Profile'.padEnd(nameWidth)} Revisions Keeping Deleting`; - console.log(chalk.dim(header)); - console.log( - chalk.dim(` ${'─'.repeat(nameWidth)} ${'─'.repeat(9)} ${'─'.repeat(8)} ${'─'.repeat(8)}`), + ui.section('Duplicate Profiles:'); + ui.table( + [ + { key: 'profile', label: 'Profile' }, + { key: 'revisions', label: 'Revisions' }, + { key: 'keeping', label: 'Keeping' }, + { key: 'deleting', label: 'Deleting' }, + ], + groups.map((g) => ({ + profile: g.name, + revisions: g.remove.length + 1, + keeping: `rev ${g.keep.revision}`, + deleting: g.remove.length, + })), ); - for (const g of groups) { - const total = g.remove.length + 1; - console.log( - ` ${g.name.padEnd(nameWidth)} ${String(total).padStart(9)} ${(`rev ${g.keep.revision}`).padStart(8)} ${String(g.remove.length).padStart(8)}`, - ); - } - const totalRemove = groups.reduce((sum, g) => sum + g.remove.length, 0); console.log( - `\n Total: ${chalk.yellow(String(totalRemove))} old revisions to delete across ${groups.length} profiles\n`, + `\n Total: ${totalRemove} old revisions to delete across ${groups.length} profiles\n`, ); } @@ -220,14 +217,19 @@ export function renderCleanupResult( } if (failed > 0) { - console.log(chalk.bold.red('\n Failures:\n')); + ui.section('Failures:'); for (const r of results.filter((r) => r.status === 'failed')) { - console.log(` ${chalk.red('✗')} ${r.name} rev ${r.revision}: ${r.error}`); + ui.bullet(`${r.name} rev ${r.revision}: ${r.error}`, 'error'); } } - const color = failed > 0 ? chalk.yellow : chalk.green; - console.log(color(`\n Cleanup complete: ${deleted} deleted, ${failed} failed\n`)); + console.log(); + if (failed > 0) { + ui.warn(`Cleanup complete: ${deleted} deleted, ${failed} failed`); + } else { + ui.success(`Cleanup complete: ${deleted} deleted, ${failed} failed`); + } + console.log(); } /** Render custom topic list. */ @@ -241,7 +243,7 @@ export function renderTopicList( format: OutputFormat = 'pretty', ): void { if (topics.length === 0) { - console.log(chalk.dim(' No topics found.\n')); + ui.emptyList('topics'); return; } if (format !== 'pretty') { @@ -265,9 +267,9 @@ export function renderTopicList( ); return; } - console.log(chalk.bold('\n Custom Topics:\n')); + ui.section('Custom Topics:'); for (const t of topics) { - console.log(` ${chalk.dim(t.topic_id)}`); + ui.dim(String(t.topic_id)); const rev = t.revision != null ? chalk.dim(` rev:${t.revision}`) : ''; const desc = t.description ? chalk.dim(` — ${t.description.slice(0, 80)}`) : ''; console.log(` ${t.topic_name}${rev}${desc}`); @@ -286,20 +288,25 @@ export function renderTopicDetail(topic: { updated_by?: string; last_modified_ts?: string; }): void { - console.log(chalk.bold('\n Topic Detail:\n')); - console.log(` ID: ${chalk.dim(topic.topic_id)}`); - console.log(` Name: ${topic.topic_name}`); - if (topic.revision != null) console.log(` Revision: ${topic.revision}`); - if (topic.description) console.log(` Description: ${topic.description}`); + ui.section('Topic Detail:'); + const pairs: Array<[string, unknown]> = [ + ['ID', topic.topic_id], + ['Name', topic.topic_name], + ]; + if (topic.revision != null) pairs.push(['Revision', topic.revision]); + if (topic.description) pairs.push(['Description', topic.description]); + ui.keyValue(pairs); if (topic.examples?.length) { - console.log(' Examples:'); + console.log(' Examples:'); for (const ex of topic.examples) { - console.log(` ${chalk.dim('•')} ${ex}`); + ui.bullet(ex, 'neutral'); } } - if (topic.created_by) console.log(` Created: ${chalk.dim(topic.created_by)}`); - if (topic.updated_by) console.log(` Updated: ${chalk.dim(topic.updated_by)}`); - if (topic.last_modified_ts) console.log(` Modified: ${chalk.dim(topic.last_modified_ts)}`); + const metaPairs: Array<[string, unknown]> = []; + if (topic.created_by) metaPairs.push(['Created', topic.created_by]); + if (topic.updated_by) metaPairs.push(['Updated', topic.updated_by]); + if (topic.last_modified_ts) metaPairs.push(['Modified', topic.last_modified_ts]); + if (metaPairs.length > 0) ui.keyValue(metaPairs); console.log(); } @@ -315,7 +322,7 @@ export function renderApiKeyList( format: OutputFormat = 'pretty', ): void { if (keys.length === 0) { - console.log(chalk.dim(' No API keys found.\n')); + ui.emptyList('API keys'); return; } if (format !== 'pretty') { @@ -341,9 +348,9 @@ export function renderApiKeyList( ); return; } - console.log(chalk.bold('\n API Keys:\n')); + ui.section('API Keys:'); for (const k of keys) { - console.log(` ${chalk.dim(k.id)}`); + ui.dim(k.id); const last8 = k.last8 ? chalk.dim(` key: …${k.last8}`) : ''; const expires = k.expiresAt ? chalk.dim(` expires: ${k.expiresAt}`) : ''; console.log(` ${k.name}${last8}${expires}`); @@ -360,13 +367,16 @@ export function renderApiKeyDetail(key: { createdAt?: string; expiresAt?: string; }): void { - console.log(chalk.bold('\n API Key Detail:\n')); - console.log(` ID: ${chalk.dim(key.id)}`); - console.log(` Name: ${key.name}`); - if (key.apiKey) console.log(` Key: ${key.apiKey}`); - else if (key.last8) console.log(` Key: ${chalk.dim('…')}${key.last8}`); - if (key.createdAt) console.log(` Created: ${chalk.dim(key.createdAt)}`); - if (key.expiresAt) console.log(` Expires: ${chalk.dim(key.expiresAt)}`); + ui.section('API Key Detail:'); + const pairs: Array<[string, unknown]> = [ + ['ID', key.id], + ['Name', key.name], + ]; + if (key.apiKey) pairs.push(['Key', key.apiKey]); + else if (key.last8) pairs.push(['Key', `…${key.last8}`]); + if (key.createdAt) pairs.push(['Created', key.createdAt]); + if (key.expiresAt) pairs.push(['Expires', key.expiresAt]); + ui.keyValue(pairs); console.log(); } @@ -376,7 +386,7 @@ export function renderCustomerAppList( format: OutputFormat = 'pretty', ): void { if (apps.length === 0) { - console.log(chalk.dim(' No customer apps found.\n')); + ui.emptyList('customer apps'); return; } if (format !== 'pretty') { @@ -398,9 +408,9 @@ export function renderCustomerAppList( ); return; } - console.log(chalk.bold('\n Customer Apps:\n')); + ui.section('Customer Apps:'); for (const a of apps) { - if (a.id) console.log(` ${chalk.dim(a.id)}`); + if (a.id) ui.dim(a.id); const desc = a.description ? chalk.dim(` — ${a.description.slice(0, 80)}`) : ''; console.log(` ${a.name}${desc}`); } @@ -414,11 +424,13 @@ export function renderCustomerAppDetail(app: { description?: string; raw: Record; }): void { - console.log(chalk.bold('\n Customer App Detail:\n')); - if (app.id) console.log(` ID: ${chalk.dim(app.id)}`); - console.log(` Name: ${app.name}`); - if (app.description) console.log(` Desc: ${app.description}`); - console.log(` Data: ${chalk.dim(JSON.stringify(app.raw, null, 2).slice(0, 500))}`); + ui.section('Customer App Detail:'); + const pairs: Array<[string, unknown]> = []; + if (app.id) pairs.push(['ID', app.id]); + pairs.push(['Name', app.name]); + if (app.description) pairs.push(['Desc', app.description]); + pairs.push(['Data', JSON.stringify(app.raw, null, 2).slice(0, 500)]); + ui.keyValue(pairs); console.log(); } @@ -494,39 +506,45 @@ export function renderCustomerAppConsumption( return; } - console.log(chalk.bold(`\n ${data.appName} ${chalk.dim('(' + data.appId + ')')}`)); - if (data.monitoringSince) console.log(` Monitoring since: ${chalk.dim(data.monitoringSince)}`); - if (data.source) console.log(` Source: ${data.source}`); - if (data.cloud) console.log(` Cloud: ${data.cloud}`); - if (data.profiles.length > 0) { - console.log(` Profiles: ${data.profiles.join(', ')}`); - } - - console.log(chalk.bold('\n Token consumption:')); - console.log( - ` Daily avg: ${fmt(data.tokens.dailyAverage, data.tokens.dailyAverageScale)}`, - ); - console.log( - ` Monthly total: ${fmt(data.tokens.monthlyTotal, data.tokens.monthlyTotalScale)}`, - ); - - console.log(chalk.bold('\n Sessions:')); - console.log(` Total: ${data.sessions.total}`); - console.log(` Violating: ${data.sessions.violating}`); + ui.header(data.appName, `(${data.appId})`); + const pairs: Array<[string, unknown]> = []; + if (data.monitoringSince) pairs.push(['Monitoring since', data.monitoringSince]); + if (data.source) pairs.push(['Source', data.source]); + if (data.cloud) pairs.push(['Cloud', data.cloud]); + if (data.profiles.length > 0) pairs.push(['Profiles', data.profiles.join(', ')]); + if (pairs.length > 0) ui.keyValue(pairs); + + ui.section('Token consumption:'); + ui.keyValue([ + ['Daily avg', fmt(data.tokens.dailyAverage, data.tokens.dailyAverageScale)], + ['Monthly total', fmt(data.tokens.monthlyTotal, data.tokens.monthlyTotalScale)], + ]); + + ui.section('Sessions:'); + ui.keyValue([ + ['Total', data.sessions.total], + ['Violating', data.sessions.violating], + ]); const firing = data.detectors.filter((d) => d.total > 0); - console.log( - chalk.bold( - `\n Detectors (${data.totalViolating} violating, ${firing.length}/${data.detectors.length} firing):`, - ), + ui.section( + `Detectors (${data.totalViolating} violating, ${firing.length}/${data.detectors.length} firing):`, ); if (firing.length === 0) { - console.log(chalk.dim(' no detector violations in window')); + ui.dim('no detector violations in window'); } else { - for (const d of firing) { - const sev = `c=${d.critical} h=${d.high} m=${d.medium} l=${d.low}`; - console.log(` ${d.type.padEnd(20)} ${String(d.total).padStart(5)} ${chalk.dim(sev)}`); - } + ui.table( + [ + { key: 'detector', label: 'Detector' }, + { key: 'total', label: 'Total' }, + { key: 'severity', label: 'Severity' }, + ], + firing.map((d) => ({ + detector: d.type, + total: d.total, + severity: `c=${d.critical} h=${d.high} m=${d.medium} l=${d.low}`, + })), + ); } console.log(); } @@ -537,7 +555,7 @@ export function renderDeploymentProfileList( format: OutputFormat = 'pretty', ): void { if (profiles.length === 0) { - console.log(chalk.dim(' No deployment profiles found.\n')); + ui.emptyList('deployment profiles'); return; } if (format !== 'pretty') { @@ -559,7 +577,7 @@ export function renderDeploymentProfileList( ); return; } - console.log(chalk.bold('\n Deployment Profiles:\n')); + ui.section('Deployment Profiles:'); for (const p of profiles) { const name = (p.raw.dp_name ?? p.raw.profile_name ?? p.raw.name ?? 'unknown') as string; const status = p.raw.status as string | undefined; @@ -579,7 +597,7 @@ export function renderScanLogList( format: OutputFormat = 'pretty', ): void { if (results.length === 0) { - console.log(chalk.dim(' No scan logs found.\n')); + ui.emptyList('scan logs'); return; } if (format !== 'pretty') { @@ -605,7 +623,7 @@ export function renderScanLogList( ); return; } - console.log(chalk.bold(`\n Scan Logs (${results.length} results):\n`)); + ui.section(`Scan Logs (${results.length} results):`); for (const r of results) { const action = (r.action ?? r.verdict) as string | undefined; const app = r.app_name as string | undefined; @@ -613,13 +631,14 @@ export function renderScanLogList( const ts = (r.received_ts ?? r.timestamp) as string | undefined; const scanId = r.scan_id as string | undefined; const actionColor = action === 'block' ? chalk.red : chalk.green; - if (scanId) console.log(` ${chalk.dim(scanId)}`); + if (scanId) ui.dim(scanId); console.log( ` ${ts ? chalk.dim(ts) : ''} ${action ? actionColor(action) : ''} ${profile ? `[${profile}]` : ''} ${app ?? ''}`, ); } if (pageToken) { - console.log(chalk.dim(`\n Page token: ${pageToken}`)); + console.log(); + ui.dim(`Page token: ${pageToken}`); } console.log(); } diff --git a/tests/unit/cli/backup-renderer.spec.ts b/tests/unit/cli/backup-renderer.spec.ts index e6e6f86..61f3acb 100644 --- a/tests/unit/cli/backup-renderer.spec.ts +++ b/tests/unit/cli/backup-renderer.spec.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { BackupResult, RestoreResult } from '../../../src/backup/types.js'; +// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are control chars by definition +const ANSI = /\x1b\[[0-9;]*m/g; +const stripAnsi = (s: string) => s.replace(ANSI, ''); + let output: string[]; const originalLog = console.log; @@ -12,7 +16,7 @@ describe('renderBackupSummary', () => { it('renders backup results with count and directory', async () => { output = []; - console.log = (...args: unknown[]) => output.push(args.join(' ')); + console.log = (...args: unknown[]) => output.push(stripAnsi(args.join(' '))); const { renderBackupSummary } = await import('../../../src/cli/renderer/backup.js'); const results: BackupResult[] = [ { name: 'target-a', filename: 'target-a.json', status: 'ok' }, @@ -24,11 +28,12 @@ describe('renderBackupSummary', () => { expect(text).toContain('target-b'); expect(text).toContain('2'); expect(text).toContain('/tmp/backups'); + expect(text).toContain('✓ target-a'); }); it('renders failed results', async () => { output = []; - console.log = (...args: unknown[]) => output.push(args.join(' ')); + console.log = (...args: unknown[]) => output.push(stripAnsi(args.join(' '))); const { renderBackupSummary } = await import('../../../src/cli/renderer/backup.js'); const results: BackupResult[] = [ { name: 'target-a', filename: 'target-a.json', status: 'failed', error: 'boom' }, @@ -37,6 +42,7 @@ describe('renderBackupSummary', () => { const text = output.join('\n'); expect(text).toContain('target-a'); expect(text).toContain('boom'); + expect(text).toContain('✗ target-a'); }); }); @@ -48,7 +54,7 @@ describe('renderRestoreSummary', () => { it('renders restore results with action totals', async () => { output = []; - console.log = (...args: unknown[]) => output.push(args.join(' ')); + console.log = (...args: unknown[]) => output.push(stripAnsi(args.join(' '))); const { renderRestoreSummary } = await import('../../../src/cli/renderer/backup.js'); const results: RestoreResult[] = [ { name: 'target-a', action: 'created' }, @@ -63,11 +69,13 @@ describe('renderRestoreSummary', () => { expect(text).toContain('updated'); expect(text).toContain('target-c'); expect(text).toContain('skipped'); + expect(text).toContain('✓ target-a'); + expect(text).toContain('○ target-c'); }); it('renders failed results with error', async () => { output = []; - console.log = (...args: unknown[]) => output.push(args.join(' ')); + console.log = (...args: unknown[]) => output.push(stripAnsi(args.join(' '))); const { renderRestoreSummary } = await import('../../../src/cli/renderer/backup.js'); const results: RestoreResult[] = [{ name: 'target-a', action: 'failed', error: 'API error' }]; renderRestoreSummary(results); diff --git a/tests/unit/cli/redteam-categories-renderer.spec.ts b/tests/unit/cli/redteam-categories-renderer.spec.ts index 9d076c9..88dcd69 100644 --- a/tests/unit/cli/redteam-categories-renderer.spec.ts +++ b/tests/unit/cli/redteam-categories-renderer.spec.ts @@ -14,7 +14,7 @@ describe('renderCategories', () => { console.log = (...args: unknown[]) => output.push(args.join(' ')); const { renderCategories } = await import('../../../src/cli/renderer/redteam.js'); renderCategories([]); - expect(output.join('\n')).toContain('No categories found.'); + expect(output.join('\n')).toContain('No categories found'); }); it('prints parent and sub-category IDs inline with display names', async () => {