diff --git a/.changeset/0000-output-discipline.md b/.changeset/0000-output-discipline.md new file mode 100644 index 0000000..0e21a42 --- /dev/null +++ b/.changeset/0000-output-discipline.md @@ -0,0 +1,5 @@ +--- +"@cdot65/prisma-airs-cli": minor +--- + +Machine-readable output is now pipe-safe: `--output json|yaml|csv` emits only the payload on stdout, with progress, banners, and rate-limit warnings moved to stderr. Exit codes are standardized (0 success, 1 runtime/API failure, 2 usage error) across every command group. API errors show the HTTP status and a `--debug` hint. Async command errors are handled properly via parseAsync. diff --git a/docs-site/docs/getting-started/exit-codes-and-output.md b/docs-site/docs/getting-started/exit-codes-and-output.md new file mode 100644 index 0000000..6dc5645 --- /dev/null +++ b/docs-site/docs/getting-started/exit-codes-and-output.md @@ -0,0 +1,32 @@ +# Exit Codes & Output Streams + +The CLI follows one contract everywhere: + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Runtime failure — API error, network failure, missing credentials, partial batch failure | +| `2` | Usage error — invalid flag value, unparsable input file, missing required flag combination | + +Scripts can rely on `2` meaning "fix the invocation" and `1` meaning "fix the +environment or retry". + +## stdout vs stderr + +- **stdout** carries data only: pretty layouts, tables, and the raw payload for + `--output json|yaml|csv`. +- **stderr** carries everything else: progress/status lines, rate-limit retry + warnings, deprecation notices, and errors. + +This means machine-readable output always pipes cleanly: + +```bash +airs runtime profiles list --output json | jq '.[].profile_name' +airs runtime bulk-scan --profile demo --input prompts.txt --output results.csv +# progress lines appear on the terminal (stderr) without corrupting the CSV +``` + +API errors include the HTTP status when available, plus a reminder that +`--debug` captures the full (secret-redacted) request/response traffic. diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index cbad6d6..b51eca6 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -16,6 +16,7 @@ const sidebars: SidebarsConfig = { 'getting-started/installation', 'getting-started/configuration', 'getting-started/quick-start', + 'getting-started/exit-codes-and-output', ], }, 'concepts/how-prisma-airs-works', diff --git a/src/cli/commands/dlp/dictionaries.ts b/src/cli/commands/dlp/dictionaries.ts index f9ba697..abca0aa 100644 --- a/src/cli/commands/dlp/dictionaries.ts +++ b/src/cli/commands/dlp/dictionaries.ts @@ -3,7 +3,7 @@ import { basename } from 'node:path'; import type { Command } from 'commander'; import { SdkDictionariesService } from '../../../airs/dlp/dictionaries.js'; import type { DictionaryRequest } from '../../../airs/dlp/types.js'; -import { dlpDictionaries, type OutputFormat, renderError } from '../../renderer/index.js'; +import { dlpDictionaries, fail, type OutputFormat, usageError } from '../../renderer/index.js'; import { buildMergePatch, parseBody } from './patch.js'; // biome-ignore lint/suspicious/noExplicitAny: opts object from commander @@ -49,8 +49,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -78,8 +77,7 @@ export function register(dlp: Command): void { }); dlpDictionaries.renderCreated(r, opts.output as OutputFormat); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(2); + usageError(err instanceof Error ? err.message : String(err)); } }); @@ -96,8 +94,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -132,8 +129,7 @@ export function register(dlp: Command): void { dlpDictionaries.renderReplaced(r, opts.output as OutputFormat); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(2); + usageError(err instanceof Error ? err.message : String(err)); } }); @@ -157,8 +153,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(2); + usageError(err instanceof Error ? err.message : String(err)); } }); @@ -170,8 +165,7 @@ export function register(dlp: Command): void { await new SdkDictionariesService().delete(id); dlpDictionaries.renderDeleted(id); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); } diff --git a/src/cli/commands/dlp/filtering-profiles.ts b/src/cli/commands/dlp/filtering-profiles.ts index d52d083..2324950 100644 --- a/src/cli/commands/dlp/filtering-profiles.ts +++ b/src/cli/commands/dlp/filtering-profiles.ts @@ -1,6 +1,6 @@ import type { Command } from 'commander'; import { SdkDataFilteringProfilesService } from '../../../airs/dlp/data-filtering-profiles.js'; -import { dlpFilteringProfiles, type OutputFormat, renderError } from '../../renderer/index.js'; +import { dlpFilteringProfiles, fail, type OutputFormat, usageError } from '../../renderer/index.js'; import { buildFilteringProfileBody, repeatable } from './build-body.js'; import { parseBody } from './patch.js'; @@ -35,8 +35,7 @@ export function register(dlp: Command): void { const r = await svc.list({ page: opts.page, size: opts.size, sort: opts.sort }); dlpFilteringProfiles.renderList(r, opts.output as OutputFormat); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -49,8 +48,7 @@ export function register(dlp: Command): void { const svc = new SdkDataFilteringProfilesService(); dlpFilteringProfiles.renderGet(await svc.get(id), opts.output as OutputFormat); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -81,8 +79,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(2); + usageError(err instanceof Error ? err.message : String(err)); } }); } diff --git a/src/cli/commands/dlp/generate.ts b/src/cli/commands/dlp/generate.ts index 416dc98..0060014 100644 --- a/src/cli/commands/dlp/generate.ts +++ b/src/cli/commands/dlp/generate.ts @@ -1,6 +1,6 @@ -import chalk from 'chalk'; import type { Command } from 'commander'; import type { Format } from '../../../dlp/types.js'; +import { fail, ui, usageError } from '../../renderer/index.js'; const ALL_FORMATS: Format[] = ['pdf', 'png', 'jpeg', 'svg', 'docx']; @@ -58,10 +58,15 @@ export function register(parent: Command): void { .option('--seed ', 'Seed for reproducible payloads') .option('--output ', 'Summary format: pretty or json', 'pretty') .action(async (opts) => { - const types = parseTypes(opts.types); + let types: Format[]; + try { + types = parseTypes(opts.types); + } catch (err) { + usageError(err instanceof Error ? err.message : String(err)); + } const count = Number.parseInt(opts.count, 10); if (!Number.isInteger(count) || count < 1) { - throw new Error('--count must be a positive integer'); + usageError('--count must be a positive integer'); } const techniques = opts.techniques === 'all' @@ -69,22 +74,28 @@ export function register(parent: Command): void { : (opts.techniques as string).split(',').map((t) => t.trim()); const seed = opts.seed === undefined ? undefined : Number.parseInt(opts.seed, 10); - const generateCorpus = await loadGenerateCorpus(); - const summary = await generateCorpus({ types, count, out: opts.out, techniques, seed }); + try { + const generateCorpus = await loadGenerateCorpus(); + const summary = await generateCorpus({ types, count, out: opts.out, techniques, seed }); - if (opts.output === 'json') { - console.log(JSON.stringify(summary, null, 2)); - return; - } + if (opts.output === 'json') { + console.log(JSON.stringify(summary, null, 2)); + return; + } - console.log(chalk.bold('\n DLP Test-File Generation')); - console.log(` Output: ${summary.out}`); - console.log(` Seed: ${summary.seed}`); - console.log(` Clean: ${summary.clean} Dirty: ${summary.dirty}`); - console.log(` Manifest: ${summary.manifestPath}\n`); - for (const [fmt, counts] of Object.entries(summary.byFormat)) { - console.log(` ${fmt.padEnd(5)} clean=${counts.clean} dirty=${counts.dirty}`); + ui.header('DLP Test-File Generation'); + ui.keyValue([ + ['Output', summary.out], + ['Seed', summary.seed], + ['Clean', summary.clean], + ['Dirty', summary.dirty], + ['Manifest', summary.manifestPath], + ]); + for (const [fmt, counts] of Object.entries(summary.byFormat)) { + ui.dim(`${fmt.padEnd(5)} clean=${counts.clean} dirty=${counts.dirty}`); + } + } catch (err) { + fail(err); } - console.log(''); }); } diff --git a/src/cli/commands/dlp/patterns.ts b/src/cli/commands/dlp/patterns.ts index 2ab044a..58fc462 100644 --- a/src/cli/commands/dlp/patterns.ts +++ b/src/cli/commands/dlp/patterns.ts @@ -1,6 +1,6 @@ import type { Command } from 'commander'; import { SdkDataPatternsService } from '../../../airs/dlp/data-patterns.js'; -import { dlpPatterns, type OutputFormat, renderError } from '../../renderer/index.js'; +import { dlpPatterns, fail, type OutputFormat, usageError } from '../../renderer/index.js'; import { buildPatternBody, repeatable } from './build-body.js'; import { buildMergePatch, parseBody } from './patch.js'; @@ -50,8 +50,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -64,8 +63,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(2); + usageError(err instanceof Error ? err.message : String(err)); } }); @@ -80,8 +78,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -95,8 +92,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(2); + usageError(err instanceof Error ? err.message : String(err)); } }, ); @@ -126,8 +122,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(2); + usageError(err instanceof Error ? err.message : String(err)); } }); @@ -139,8 +134,7 @@ export function register(dlp: Command): void { await new SdkDataPatternsService().delete(id); dlpPatterns.renderArchived(id); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); } diff --git a/src/cli/commands/dlp/profiles.ts b/src/cli/commands/dlp/profiles.ts index 6465d81..90d190a 100644 --- a/src/cli/commands/dlp/profiles.ts +++ b/src/cli/commands/dlp/profiles.ts @@ -1,6 +1,6 @@ import type { Command } from 'commander'; import { SdkDataProfilesService } from '../../../airs/dlp/data-profiles.js'; -import { dlpProfiles, type OutputFormat, renderError } from '../../renderer/index.js'; +import { dlpProfiles, fail, type OutputFormat, usageError } from '../../renderer/index.js'; import { buildProfileBody, repeatable } from './build-body.js'; import { buildMergePatch, parseBody } from './patch.js'; @@ -60,8 +60,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -74,8 +73,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(2); + usageError(err instanceof Error ? err.message : String(err)); } }); @@ -90,8 +88,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -105,8 +102,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(2); + usageError(err instanceof Error ? err.message : String(err)); } }, ); @@ -136,8 +132,7 @@ export function register(dlp: Command): void { opts.output as OutputFormat, ); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(2); + usageError(err instanceof Error ? err.message : String(err)); } }); @@ -145,14 +140,12 @@ export function register(dlp: Command): void { .command('delete ') .description('Not supported — prints the patch idiom and exits 2') .action((id) => { - console.error(` -This DLP API has no DELETE for data profiles. -To soft-delete, fetch the profile to get its name + profile_type, then patch: - - airs runtime dlp profiles get ${id} --output json - airs runtime dlp profiles patch ${id} --set profile_status='"deleted"' \\ - --set name='""' --set profile_type='""' -`); - process.exit(2); + usageError( + `This DLP API has no DELETE for data profiles.\n` + + ` To soft-delete, fetch the profile to get its name + profile_type, then patch:\n\n` + + ` airs runtime dlp profiles get ${id} --output json\n` + + ` airs runtime dlp profiles patch ${id} --set profile_status='"deleted"' \\\n` + + ` --set name='""' --set profile_type='""'`, + ); }); } diff --git a/src/cli/commands/modelsecurity.ts b/src/cli/commands/modelsecurity.ts index 4af96bd..cd87273 100644 --- a/src/cli/commands/modelsecurity.ts +++ b/src/cli/commands/modelsecurity.ts @@ -1,13 +1,12 @@ import { execFileSync, spawn } from 'node:child_process'; import * as fs from 'node:fs'; -import chalk from 'chalk'; import type { Command } from 'commander'; import { SdkModelSecurityService } from '../../airs/modelsecurity.js'; import { modelSecurityClientOptions } from '../../config/client-options.js'; import { loadConfig } from '../../config/loader.js'; import { + fail, type OutputFormat, - renderError, renderEvaluationDetail, renderEvaluationList, renderFileList, @@ -24,6 +23,8 @@ import { renderRuleList, renderViolationDetail, renderViolationList, + ui, + usageError, } from '../renderer/index.js'; const VALID_EXTRAS = ['all', 'aws', 'gcp', 'azure', 'artifactory', 'gitlab'] as const; @@ -96,8 +97,7 @@ export function registerModelSecurityCommand(program: Command): void { }); renderGroupList(result.groups, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -113,8 +113,7 @@ export function registerModelSecurityCommand(program: Command): void { const group = await service.getGroup(uuid); renderGroupDetail(group, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -133,11 +132,10 @@ export function registerModelSecurityCommand(program: Command): void { description: config.description, ruleConfigurations: config.rule_configurations, }); - console.log(` Group created: ${group.uuid}\n`); + ui.success(`Group created: ${group.uuid}`); renderGroupDetail(group); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -154,11 +152,10 @@ export function registerModelSecurityCommand(program: Command): void { if (opts.name) request.name = opts.name; if (opts.description) request.description = opts.description; const group = await service.updateGroup(uuid, request); - console.log(` Group updated: ${group.uuid}\n`); + ui.success(`Group updated: ${group.uuid}`); renderGroupDetail(group); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -171,16 +168,15 @@ export function registerModelSecurityCommand(program: Command): void { const service = await createService(); const { confirmed, state } = await service.deleteGroupAndVerify(uuid); if (confirmed) { - console.log(` Group ${uuid} deleted.\n`); + ui.success(`Group ${uuid} deleted.`); } else { - console.log( - ` Delete request accepted, but group ${uuid} still reports state '${state}'.\n` + - ` Deletion is asynchronous (soft-delete) — re-check with \`model-security groups get ${uuid}\`.\n`, + ui.warn(`Delete request accepted, but group ${uuid} still reports state '${state}'.`); + ui.dim( + `Deletion is asynchronous (soft-delete) — re-check with \`model-security groups get ${uuid}\`.`, ); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -203,16 +199,14 @@ export function registerModelSecurityCommand(program: Command): void { // Validate extras const extras = opts.extras as string; if (!VALID_EXTRAS.includes(extras as (typeof VALID_EXTRAS)[number])) { - renderError(`Invalid extras "${extras}". Valid: ${VALID_EXTRAS.join(', ')}`); - process.exit(1); + usageError(`Invalid extras "${extras}". Valid: ${VALID_EXTRAS.join(', ')}`); } const dir = opts.dir as string; const useUv = hasBin('uv'); if (!useUv && !hasBin('python3')) { - renderError('Neither uv nor python3 found on PATH. Install one first.'); - process.exit(1); + fail(new Error('Neither uv nor python3 found on PATH. Install one first.')); } // Fetch PyPI auth URL @@ -241,32 +235,30 @@ export function registerModelSecurityCommand(program: Command): void { ]; if (opts.dryRun) { - console.log(chalk.bold('\n Commands that would be executed:\n')); + ui.section('Commands that would be executed'); for (const step of steps) { const cmdStr = [step.bin, ...step.args] .map((a) => (a.includes('[') || a.includes(' ') ? `"${a}"` : a)) .join(' '); - console.log(` ${chalk.dim('$')} ${cmdStr}`); + ui.dim(`$ ${cmdStr}`); } - console.log(); return; } for (const step of steps) { - console.log(chalk.dim(`\n → ${step.label}\n`)); + ui.status(`→ ${step.label}`); await run(step.bin, step.args, step.label); } - console.log(chalk.green('\n model-security-client installed successfully.\n')); + ui.success('model-security-client installed successfully.'); if (useUv) { - console.log(chalk.dim(` Activate: cd ${dir}\n`)); + ui.dim(`Activate: cd ${dir}`); } else { - console.log(chalk.dim(` Activate: source ${dir}/.venv/bin/activate\n`)); + ui.dim(`Activate: source ${dir}/.venv/bin/activate`); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -285,10 +277,9 @@ export function registerModelSecurityCommand(program: Command): void { const service = await createService(); const parsed = JSON.parse(opts.labels); await service.addLabels(scanUuid, parsed); - console.log(' Labels added.\n'); + ui.success('Labels added.'); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -302,10 +293,9 @@ export function registerModelSecurityCommand(program: Command): void { const service = await createService(); const parsed = JSON.parse(opts.labels); await service.setLabels(scanUuid, parsed); - console.log(' Labels set.\n'); + ui.success('Labels set.'); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -319,10 +309,9 @@ export function registerModelSecurityCommand(program: Command): void { const service = await createService(); const keys = (opts.keys as string).split(',').map((k: string) => k.trim()); await service.deleteLabels(scanUuid, keys); - console.log(' Labels deleted.\n'); + ui.success('Labels deleted.'); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -339,8 +328,7 @@ export function registerModelSecurityCommand(program: Command): void { }); renderLabelKeys(result.keys); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -357,8 +345,7 @@ export function registerModelSecurityCommand(program: Command): void { }); renderLabelValues(key, result.values); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -372,13 +359,13 @@ export function registerModelSecurityCommand(program: Command): void { renderModelSecurityHeader(); const service = await createService(); const auth = await service.getPyPIAuth(); - console.log(chalk.bold('\n PyPI Authentication:\n')); - console.log(` URL: ${auth.url}`); - console.log(` Expires: ${chalk.dim(auth.expiresAt)}`); - console.log(); + ui.section('PyPI Authentication'); + ui.keyValue([ + ['URL', auth.url], + ['Expires', auth.expiresAt], + ]); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -404,8 +391,7 @@ export function registerModelSecurityCommand(program: Command): void { }); renderRuleInstanceList(result.ruleInstances); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -419,8 +405,7 @@ export function registerModelSecurityCommand(program: Command): void { const instance = await service.getRuleInstance(groupUuid, instanceUuid); renderRuleInstanceDetail(instance); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -437,11 +422,10 @@ export function registerModelSecurityCommand(program: Command): void { state: config.state, fieldValues: config.field_values, }); - console.log(` Rule instance updated: ${instance.uuid}\n`); + ui.success(`Rule instance updated: ${instance.uuid}`); renderRuleInstanceDetail(instance); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -469,8 +453,7 @@ export function registerModelSecurityCommand(program: Command): void { }); renderRuleList(result.rules, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -484,8 +467,7 @@ export function registerModelSecurityCommand(program: Command): void { const rule = await service.getRule(uuid); renderRuleDetail(rule); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -517,8 +499,7 @@ export function registerModelSecurityCommand(program: Command): void { }); renderMsScanList(result.scans, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -532,8 +513,7 @@ export function registerModelSecurityCommand(program: Command): void { const scan = await service.getScan(uuid); renderMsScanDetail(scan); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -547,11 +527,10 @@ export function registerModelSecurityCommand(program: Command): void { const service = await createService(); const config = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); const scan = await service.createScan(config); - console.log(` Scan created: ${scan.uuid}\n`); + ui.success(`Scan created: ${scan.uuid}`); renderMsScanDetail(scan); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -568,8 +547,7 @@ export function registerModelSecurityCommand(program: Command): void { }); renderEvaluationList(result.evaluations); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -583,8 +561,7 @@ export function registerModelSecurityCommand(program: Command): void { const evaluation = await service.getEvaluation(uuid); renderEvaluationDetail(evaluation); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -601,8 +578,7 @@ export function registerModelSecurityCommand(program: Command): void { }); renderViolationList(result.violations); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -616,8 +592,7 @@ export function registerModelSecurityCommand(program: Command): void { const violation = await service.getViolation(uuid); renderViolationDetail(violation); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -638,8 +613,7 @@ export function registerModelSecurityCommand(program: Command): void { }); renderFileList(result.files); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); } diff --git a/src/cli/commands/profiles-cleanup.ts b/src/cli/commands/profiles-cleanup.ts index f5a5b82..125c45e 100644 --- a/src/cli/commands/profiles-cleanup.ts +++ b/src/cli/commands/profiles-cleanup.ts @@ -1,12 +1,12 @@ import { execSync } from 'node:child_process'; -import chalk from 'chalk'; import type { Command } from 'commander'; import type { SecurityProfileInfo } from '../../airs/types.js'; import { + fail, renderCleanupPreview, renderCleanupResult, - renderError, renderRuntimeConfigHeader, + ui, } from '../renderer/index.js'; import { createMgmtService } from './runtime.js'; @@ -94,7 +94,7 @@ export function registerCleanupCommand(parent: Command): void { if (fmt === 'json') { console.log(JSON.stringify({ duplicates: [], summary: { deleted: 0, failed: 0 } })); } else { - console.log(chalk.green('\n No duplicate profiles found.\n')); + ui.success('No duplicate profiles found.'); } return; } @@ -103,7 +103,7 @@ export function registerCleanupCommand(parent: Command): void { if (!opts.force) { if (fmt === 'pretty') { - console.log(` Pass ${chalk.bold('--force')} to delete these revisions.\n`); + ui.dim('Pass --force to delete these revisions.'); } return; } @@ -117,13 +117,13 @@ export function registerCleanupCommand(parent: Command): void { await service.forceDeleteProfile(entry.id, updatedBy); results.push({ ...entry, name: group.name, status: 'ok' }); if (fmt === 'pretty') { - console.log(` ${chalk.green('✓')} ${group.name} rev ${entry.revision}`); + ui.bullet(`${group.name} rev ${entry.revision}`, 'success'); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); results.push({ ...entry, name: group.name, status: 'failed', error: msg }); if (fmt === 'pretty') { - console.log(` ${chalk.red('✗')} ${group.name} rev ${entry.revision}: ${msg}`); + ui.bullet(`${group.name} rev ${entry.revision}: ${msg}`, 'error'); } } } @@ -134,8 +134,7 @@ export function registerCleanupCommand(parent: Command): void { const failed = results.filter((r) => r.status === 'failed').length; if (failed > 0) process.exit(1); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); } diff --git a/src/cli/commands/redteam.ts b/src/cli/commands/redteam.ts index 1c82c36..a50d755 100644 --- a/src/cli/commands/redteam.ts +++ b/src/cli/commands/redteam.ts @@ -1,6 +1,5 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import chalk from 'chalk'; import type { Command } from 'commander'; import { SdkPromptSetService } from '../../airs/promptsets.js'; import { SdkRedTeamService } from '../../airs/redteam.js'; @@ -10,6 +9,7 @@ import { redTeamClientOptions } from '../../config/client-options.js'; import { loadConfig } from '../../config/loader.js'; import { buildAttackListFootnote, + fail, type OutputFormat, renderAttackList, renderAuthValidation, @@ -19,7 +19,6 @@ import { renderCustomAttackList, renderCustomReport, renderDynamicReport, - renderError, renderEulaContent, renderEulaStatus, renderInstanceDetail, @@ -42,6 +41,8 @@ import { renderTargetTemplates, renderVersionInfo, renderVersionInfoUnavailable, + ui, + usageError, } from '../renderer/index.js'; import { backupTargets } from './backup.js'; import { restoreTargets } from './restore.js'; @@ -129,10 +130,9 @@ export function registerRedteamCommand(program: Command): void { renderRedteamHeader(); const service = await createService(); await service.abortScan(jobId); - console.log(` Scan ${jobId} aborted.\n`); + ui.success(`Scan ${jobId} aborted.`); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -149,8 +149,7 @@ export function registerRedteamCommand(program: Command): void { const categories = await service.getCategories(); renderCategories(categories); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -169,8 +168,7 @@ export function registerRedteamCommand(program: Command): void { const status = await service.getEulaStatus(); renderEulaStatus(status); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -184,8 +182,7 @@ export function registerRedteamCommand(program: Command): void { const content = await service.getEulaContent(); renderEulaContent(content); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -201,16 +198,15 @@ export function registerRedteamCommand(program: Command): void { if (!opts.confirm) { renderEulaContent(content); - console.log(' Pass --confirm to accept.\n'); + ui.dim('Pass --confirm to accept.'); return; } const result = await service.acceptEula(content.content); renderEulaStatus(result); - console.log(' EULA accepted.\n'); + ui.success('EULA accepted.'); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -238,8 +234,7 @@ export function registerRedteamCommand(program: Command): void { }); renderInstanceResponse(result); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -253,8 +248,7 @@ export function registerRedteamCommand(program: Command): void { const result = await service.getInstance(tenantId); renderInstanceDetail(result); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -276,8 +270,7 @@ export function registerRedteamCommand(program: Command): void { }); renderInstanceResponse(result); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -290,10 +283,9 @@ export function registerRedteamCommand(program: Command): void { const service = await createService(); const result = await service.deleteInstance(tenantId); renderInstanceResponse(result); - console.log(` Instance ${tenantId} deleted.\n`); + ui.success(`Instance ${tenantId} deleted.`); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -312,11 +304,10 @@ export function registerRedteamCommand(program: Command): void { const service = await createService(); const config = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); const result = await service.createDevices(tenantId, config); - console.log(' Devices created:'); - console.log(` ${JSON.stringify(result, null, 2)}\n`); + ui.success('Devices created:'); + console.log(JSON.stringify(result, null, 2)); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -330,11 +321,10 @@ export function registerRedteamCommand(program: Command): void { const service = await createService(); const config = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); const result = await service.updateDevices(tenantId, config); - console.log(' Devices updated:'); - console.log(` ${JSON.stringify(result, null, 2)}\n`); + ui.success('Devices updated:'); + console.log(JSON.stringify(result, null, 2)); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -347,11 +337,10 @@ export function registerRedteamCommand(program: Command): void { renderRedteamHeader(); const service = await createService(); const result = await service.deleteDevices(tenantId, opts.serialNumbers); - console.log(' Devices deleted:'); - console.log(` ${JSON.stringify(result, null, 2)}\n`); + ui.success('Devices deleted:'); + console.log(JSON.stringify(result, null, 2)); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -368,8 +357,7 @@ export function registerRedteamCommand(program: Command): void { const creds = await service.getRegistryCredentials(); renderRegistryCredentials(creds); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -397,8 +385,7 @@ export function registerRedteamCommand(program: Command): void { }); renderScanList(scans, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -419,8 +406,7 @@ export function registerRedteamCommand(program: Command): void { const sets = await service.listPromptSets(); renderPromptSetList(sets, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -442,8 +428,7 @@ export function registerRedteamCommand(program: Command): void { renderPromptSetDetail(ps, fmt, info); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -457,11 +442,10 @@ export function registerRedteamCommand(program: Command): void { renderRedteamHeader(); const service = await createPromptSetService(); const result = await service.createPromptSet(opts.name, opts.description); - console.log(` Prompt set created: ${result.uuid}\n`); - console.log(` Name: ${result.name}\n`); + ui.success(`Prompt set created: ${result.uuid}`); + ui.keyValue([['Name', result.name]]); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -480,8 +464,7 @@ export function registerRedteamCommand(program: Command): void { const result = await service.updatePromptSet(uuid, request); renderPromptSetDetail(result); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -495,10 +478,9 @@ export function registerRedteamCommand(program: Command): void { const service = await createPromptSetService(); const archive = !opts.unarchive; await service.archivePromptSet(uuid, archive); - console.log(` Prompt set ${uuid} ${archive ? 'archived' : 'unarchived'}.\n`); + ui.success(`Prompt set ${uuid} ${archive ? 'archived' : 'unarchived'}.`); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -513,10 +495,9 @@ export function registerRedteamCommand(program: Command): void { const csv = await service.downloadTemplate(uuid); const outPath = opts.output || `${uuid}-template.csv`; fs.writeFileSync(outPath, csv, 'utf-8'); - console.log(` Template saved to ${outPath}\n`); + ui.success(`Template saved to ${outPath}`); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -531,10 +512,9 @@ export function registerRedteamCommand(program: Command): void { const filename = path.basename(file); const blob = new File([content], filename, { type: 'text/csv' }); const result = await service.uploadPromptsCsv(uuid, blob); - console.log(` ${result.message}\n`); + ui.success(result.message); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -556,8 +536,7 @@ export function registerRedteamCommand(program: Command): void { }); renderPromptList(list); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -571,8 +550,7 @@ export function registerRedteamCommand(program: Command): void { const prompt = await service.getPrompt(setUuid, promptUuid); renderPromptDetail(prompt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -586,10 +564,9 @@ export function registerRedteamCommand(program: Command): void { renderRedteamHeader(); const service = await createPromptSetService(); const result = await service.addPrompt(setUuid, opts.prompt, opts.goal); - console.log(` Prompt added: ${result.uuid}\n`); + ui.success(`Prompt added: ${result.uuid}`); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -608,8 +585,7 @@ export function registerRedteamCommand(program: Command): void { const result = await service.updatePrompt(setUuid, promptUuid, request); renderPromptDetail(result); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -621,10 +597,9 @@ export function registerRedteamCommand(program: Command): void { renderRedteamHeader(); const service = await createPromptSetService(); await service.deletePrompt(setUuid, promptUuid); - console.log(` Prompt ${promptUuid} deleted.\n`); + ui.success(`Prompt ${promptUuid} deleted.`); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -645,8 +620,7 @@ export function registerRedteamCommand(program: Command): void { const names = await service.getPropertyNames(); renderPropertyNames(names, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -659,10 +633,9 @@ export function registerRedteamCommand(program: Command): void { renderRedteamHeader(); const service = await createPromptSetService(); const result = await service.createPropertyName(opts.name); - console.log(` ${result.message}\n`); + ui.success(result.message); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -678,8 +651,7 @@ export function registerRedteamCommand(program: Command): void { const values = await service.getPropertyValues(name); renderPropertyValues(values, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -693,10 +665,9 @@ export function registerRedteamCommand(program: Command): void { renderRedteamHeader(); const service = await createPromptSetService(); const result = await service.createPropertyValue(opts.name, opts.value); - console.log(` ${result.message}\n`); + ui.success(result.message); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -745,8 +716,7 @@ export function registerRedteamCommand(program: Command): void { } } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -766,24 +736,30 @@ export function registerRedteamCommand(program: Command): void { .option('--breadth ', 'Parallel agents per goal (DYNAMIC scans)', '6') .option('--no-wait', 'Submit scan without waiting for completion') .action(async (opts) => { + let categories: Record | undefined; + let attackGoals: string[] | undefined; + let streamDepth: number; + let streamBreadth: number; try { - renderRedteamHeader(); - const service = await createService(); - - let categories: Record | undefined; if (opts.categories) { categories = JSON.parse(opts.categories); } + attackGoals = opts.goals ? parseAttackGoals(opts.goals as string) : undefined; + streamDepth = parsePositiveInt(opts.depth as string, '--depth'); + streamBreadth = parsePositiveInt(opts.breadth as string, '--breadth'); + } catch (err) { + usageError(err instanceof Error ? err.message : String(err)); + } - const customPromptSets = opts.promptSets - ? (opts.promptSets as string).split(',').map((s: string) => s.trim()) - : undefined; + const customPromptSets = opts.promptSets + ? (opts.promptSets as string).split(',').map((s: string) => s.trim()) + : undefined; - const attackGoals = opts.goals ? parseAttackGoals(opts.goals as string) : undefined; - const streamDepth = parsePositiveInt(opts.depth as string, '--depth'); - const streamBreadth = parsePositiveInt(opts.breadth as string, '--breadth'); + try { + renderRedteamHeader(); + const service = await createService(); - console.log(` Creating ${opts.type} scan "${opts.name}"...`); + ui.status(`Creating ${opts.type} scan "${opts.name}"...`); const job = await service.createScan({ name: opts.name, targetUuid: opts.target, @@ -798,21 +774,20 @@ export function registerRedteamCommand(program: Command): void { renderScanStatus(job); if (opts.wait !== false) { - console.log(' Waiting for completion...\n'); + ui.status('Waiting for completion...'); const completed = await service.waitForCompletion(job.uuid, (progress) => renderScanProgress(progress), ); console.log('\n'); renderScanStatus(completed); - console.log(` Job ID: ${completed.uuid}`); - console.log(' Run `airs redteam report ` to view results.\n'); + ui.keyValue([['Job ID', completed.uuid]]); + ui.dim('Run `airs redteam report ` to view results.'); } else { - console.log(` Job ID: ${job.uuid}`); - console.log(' Run `airs redteam status ` to check progress.\n'); + ui.keyValue([['Job ID', job.uuid]]); + ui.dim('Run `airs redteam status ` to check progress.'); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -829,8 +804,7 @@ export function registerRedteamCommand(program: Command): void { const job = await service.getScan(jobId); renderScanStatus(job); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -851,8 +825,7 @@ export function registerRedteamCommand(program: Command): void { const list = await service.listTargets(); renderTargetList(list, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -868,8 +841,7 @@ export function registerRedteamCommand(program: Command): void { const target = await service.getTarget(uuid); renderTargetDetail(target, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -887,11 +859,10 @@ export function registerRedteamCommand(program: Command): void { config, opts.validate ? { validate: true } : undefined, ); - console.log(` Target created: ${target.uuid}\n`); + ui.success(`Target created: ${target.uuid}`); renderTargetDetail(target); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -910,11 +881,10 @@ export function registerRedteamCommand(program: Command): void { config, opts.validate ? { validate: true } : undefined, ); - console.log(` Target updated: ${target.uuid}\n`); + ui.success(`Target updated: ${target.uuid}`); renderTargetDetail(target); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -926,10 +896,9 @@ export function registerRedteamCommand(program: Command): void { renderRedteamHeader(); const service = await createService(); await service.deleteTarget(uuid); - console.log(` Target ${uuid} deleted.\n`); + ui.success(`Target ${uuid} deleted.`); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -943,11 +912,10 @@ export function registerRedteamCommand(program: Command): void { const service = await createService(); const config = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); const result = await service.probeTarget(config); - console.log(' Probe result:'); - console.log(` ${JSON.stringify(result, null, 2)}\n`); + ui.dim('Probe result:'); + console.log(JSON.stringify(result, null, 2)); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -959,11 +927,10 @@ export function registerRedteamCommand(program: Command): void { renderRedteamHeader(); const service = await createService(); const profile = await service.getTargetProfile(uuid); - console.log(' Target Profile:'); - console.log(` ${JSON.stringify(profile, null, 2)}\n`); + ui.dim('Target Profile:'); + console.log(JSON.stringify(profile, null, 2)); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -977,11 +944,10 @@ export function registerRedteamCommand(program: Command): void { const service = await createService(); const config = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); const result = await service.updateTargetProfile(uuid, config); - console.log(' Profile updated:'); - console.log(` ${JSON.stringify(result, null, 2)}\n`); + ui.success('Profile updated:'); + console.log(JSON.stringify(result, null, 2)); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -1003,8 +969,7 @@ export function registerRedteamCommand(program: Command): void { }); renderAuthValidation(result); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -1013,13 +978,11 @@ export function registerRedteamCommand(program: Command): void { .description('Get target field metadata') .action(async () => { try { - renderRedteamHeader(); const service = await createService(); const metadata = await service.getTargetMetadata(); console.log(JSON.stringify(metadata, null, 2)); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -1028,32 +991,35 @@ export function registerRedteamCommand(program: Command): void { .description('Scaffold a target config JSON from a provider template') .option('--output ', 'Output file path') .action(async (provider: string, opts) => { + if ( + !VALID_TARGET_PROVIDERS.includes( + provider.toUpperCase() as (typeof VALID_TARGET_PROVIDERS)[number], + ) + ) { + usageError( + `Unknown provider "${provider}". Valid providers: ${VALID_TARGET_PROVIDERS.join(', ')}`, + ); + } + const filename = opts.output ?? `${provider.toLowerCase()}-target.json`; + const outputPath = path.resolve(filename); + if (fs.existsSync(outputPath)) { + usageError(`File already exists: ${outputPath} (use --output to specify a different path)`); + } try { renderRedteamHeader(); const service = await createService(); const templates = await service.getTargetTemplates(); const scaffold = buildTargetScaffold(provider, templates); - const filename = opts.output ?? `${provider.toLowerCase()}-target.json`; - const outputPath = path.resolve(filename); - if (fs.existsSync(outputPath)) { - renderError( - `File already exists: ${outputPath} (use --output to specify a different path)`, - ); - process.exit(1); - } fs.writeFileSync(outputPath, `${JSON.stringify(scaffold, null, 2)}\n`); - console.log(chalk.bold('\n Target config scaffolded:\n')); - console.log(` File: ${chalk.cyan(outputPath)}`); - console.log(` Provider: ${chalk.dim(provider.toUpperCase())}`); - console.log( - `\n ${chalk.yellow('Next steps:')} Edit the file to fill in ${chalk.bold('name')} and credentials, then run:`, - ); - console.log( - ` ${chalk.cyan(`airs redteam targets create --config ${filename} --validate`)}\n`, - ); + ui.success('Target config scaffolded'); + ui.keyValue([ + ['File', outputPath], + ['Provider', provider.toUpperCase()], + ]); + ui.dim('Next steps: edit the file to fill in name and credentials, then run:'); + ui.dim(` airs redteam targets create --config ${filename} --validate`); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -1067,8 +1033,7 @@ export function registerRedteamCommand(program: Command): void { const templates = await service.getTargetTemplates(); renderTargetTemplates(templates); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -1091,8 +1056,7 @@ export function registerRedteamCommand(program: Command): void { const failed = results.filter((r) => r.status === 'failed').length; if (failed > 0) process.exit(1); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -1104,11 +1068,11 @@ export function registerRedteamCommand(program: Command): void { .option('--overwrite', 'Update existing targets with same name (default: skip)') .option('--validate', 'Validate target connection before saving') .action(async (opts) => { + if (!opts.file && !opts.inputDir) { + usageError('Specify --file or --input-dir '); + } try { renderBackupHeader(); - if (!opts.file && !opts.inputDir) { - throw new Error('Specify --file or --input-dir '); - } const results = await restoreTargets({ file: opts.file, inputDir: opts.inputDir, @@ -1119,8 +1083,7 @@ export function registerRedteamCommand(program: Command): void { const failed = results.filter((r) => r.action === 'failed').length; if (failed > 0) process.exit(1); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); } diff --git a/src/cli/commands/runtime.ts b/src/cli/commands/runtime.ts index 3b8f981..a08a03a 100644 --- a/src/cli/commands/runtime.ts +++ b/src/cli/commands/runtime.ts @@ -15,6 +15,7 @@ import { import { loadBulkScanState, saveBulkScanState } from '../bulk-scan-state.js'; import { parseInputFile } from '../parse-input.js'; import { + fail, OUTPUT_FORMATS, type OutputFormat, renderApiKeyDetail, @@ -23,13 +24,14 @@ import { renderCustomerAppDetail, renderCustomerAppList, renderDeploymentProfileList, - renderError, renderProfileDetail, renderProfileList, renderRuntimeConfigHeader, renderScanLogList, renderTopicDetail, renderTopicList, + ui, + usageError, } from '../renderer/index.js'; import { registerDlpCommands } from './dlp/index.js'; import { registerCleanupCommand } from './profiles-cleanup.js'; @@ -41,22 +43,22 @@ import { registerSampleCommand } from './topics-sample.js'; function renderScanResult(result: RuntimeScanResult): void { const actionColor = result.action === 'block' ? chalk.red : chalk.green; - console.log(chalk.bold('\n Scan Result')); - console.log(chalk.dim(' ─────────────────────────')); - console.log(` Action: ${actionColor(result.action.toUpperCase())}`); - console.log(` Category: ${result.category}`); - console.log(` Triggered: ${result.triggered ? chalk.red('yes') : chalk.green('no')}`); - console.log(` Scan ID: ${chalk.dim(result.scanId)}`); - console.log(` Report ID: ${chalk.dim(result.reportId)}`); + ui.header('Scan Result'); + ui.keyValue([ + ['Action', actionColor(result.action.toUpperCase())], + ['Category', result.category], + ['Triggered', result.triggered ? chalk.red('yes') : chalk.green('no')], + ['Scan ID', chalk.dim(result.scanId)], + ['Report ID', chalk.dim(result.reportId)], + ]); const flags = Object.entries(result.detections).filter(([, v]) => v); if (flags.length > 0) { - console.log(chalk.bold('\n Detections:')); + ui.section('Detections'); for (const [key] of flags) { - console.log(` ${chalk.yellow('●')} ${key}`); + ui.bullet(key, 'flag'); } } - console.log(); } /** Create a management service from config. */ @@ -95,8 +97,7 @@ export function registerRuntimeCommand(program: Command): void { }); renderApiKeyList(result.apiKeys, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -110,11 +111,10 @@ export function registerRuntimeCommand(program: Command): void { const service = await createMgmtService(); const config = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); const key = await service.createApiKey(config); - console.log(` API key created: ${key.id}\n`); + ui.success(`API key created: ${key.id}`); renderApiKeyDetail(key); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -134,11 +134,10 @@ export function registerRuntimeCommand(program: Command): void { }; if (opts.updatedBy) request.updated_by = opts.updatedBy; const key = await service.regenerateApiKey(apiKeyId, request); - console.log(` API key regenerated: ${key.id}\n`); + ui.success(`API key regenerated: ${key.id}`); renderApiKeyDetail(key); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -151,10 +150,9 @@ export function registerRuntimeCommand(program: Command): void { renderRuntimeConfigHeader(); const service = await createMgmtService(); const result = await service.deleteApiKey(apiKeyName, opts.updatedBy); - console.log(` ${result.message}\n`); + ui.success(result.message); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -175,28 +173,26 @@ export function registerRuntimeCommand(program: Command): void { try { const config = await loadConfig({}); if (!config.airsApiKey && !config.airsApiToken) { - renderError('PANW_AI_SEC_API_KEY or PANW_AI_SEC_API_TOKEN is required'); - process.exit(1); + fail(new Error('PANW_AI_SEC_API_KEY or PANW_AI_SEC_API_TOKEN is required')); } const raw = await readFile(opts.input, 'utf-8'); const prompts = parseInputFile(raw, opts.input); if (prompts.length === 0) { - renderError('No prompts found in input file'); - process.exit(1); + usageError('No prompts found in input file'); } const sessionId = opts.sessionId ?? `prisma-airs-cli-bulk-${Date.now().toString(36)}`; const service = new SdkRuntimeService(runtimeInitOptions(config)); - console.log(chalk.bold.cyan('\n Prisma AIRS Bulk Scan')); - console.log(chalk.dim(` Profile: ${opts.profile}`)); - console.log(chalk.dim(` Session: ${sessionId}`)); - console.log(chalk.dim(` Prompts: ${prompts.length}`)); - console.log(chalk.dim(` Batches: ${Math.ceil(prompts.length / 5)}\n`)); + ui.status('Prisma AIRS Bulk Scan'); + ui.status(`Profile: ${opts.profile}`); + ui.status(`Session: ${sessionId}`); + ui.status(`Prompts: ${prompts.length}`); + ui.status(`Batches: ${Math.ceil(prompts.length / 5)}`); - console.log(chalk.dim(' Submitting async scans...')); + ui.status('Submitting async scans...'); const scanIds = await service.submitBulkScan(opts.profile, prompts, sessionId); const stateDir = config.dataDir.replace(/\/runs$/, '/bulk-scans'); @@ -204,16 +200,12 @@ export function registerRuntimeCommand(program: Command): void { { scanIds, profile: opts.profile, promptCount: prompts.length, sessionId }, stateDir, ); - console.log(chalk.dim(` Scan IDs saved: ${statePath}`)); - console.log(chalk.dim(` Submitted ${scanIds.length} batch(es), polling for results...`)); + ui.status(`Scan IDs saved: ${statePath}`); + ui.status(`Submitted ${scanIds.length} batch(es), polling for results...`); const results = await service.pollResults(scanIds, undefined, { onRetry: (attempt, delayMs) => { - console.log( - chalk.yellow( - ` ⚠ Rate limited — retry ${attempt} in ${(delayMs / 1000).toFixed(0)}s...`, - ), - ); + ui.status(`⚠ Rate limited — retry ${attempt} in ${(delayMs / 1000).toFixed(0)}s...`); }, }); @@ -229,15 +221,15 @@ export function registerRuntimeCommand(program: Command): void { const blocked = results.filter((r) => r.action === 'block').length; const allowed = results.filter((r) => r.action === 'allow').length; - console.log(chalk.bold('\n Bulk Scan Complete')); - console.log(chalk.dim(' ─────────────────────────')); - console.log(` Total: ${results.length}`); - console.log(` Blocked: ${chalk.red(String(blocked))}`); - console.log(` Allowed: ${chalk.green(String(allowed))}`); - console.log(` Output: ${chalk.cyan(outputPath)}\n`); + ui.header('Bulk Scan Complete'); + ui.keyValue([ + ['Total', results.length], + ['Blocked', chalk.red(String(blocked))], + ['Allowed', chalk.green(String(allowed))], + ['Output', chalk.cyan(outputPath)], + ]); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -261,8 +253,7 @@ export function registerRuntimeCommand(program: Command): void { }); renderCustomerAppList(result.apps, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -276,8 +267,7 @@ export function registerRuntimeCommand(program: Command): void { const app = await service.getCustomerApp(appName); renderCustomerAppDetail(app); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -291,11 +281,10 @@ export function registerRuntimeCommand(program: Command): void { const service = await createMgmtService(); const config = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); const app = await service.updateCustomerApp(appId, config); - console.log(` Customer app updated: ${app.name}\n`); + ui.success(`Customer app updated: ${app.name}`); renderCustomerAppDetail(app); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -308,10 +297,9 @@ export function registerRuntimeCommand(program: Command): void { renderRuntimeConfigHeader(); const service = await createMgmtService(); const app = await service.deleteCustomerApp(appName, opts.updatedBy); - console.log(` Customer app "${app.name}" deleted.\n`); + ui.success(`Customer app "${app.name}" deleted.`); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -331,8 +319,7 @@ export function registerRuntimeCommand(program: Command): void { const fmt = opts.output as OutputFormat; const interval = Number.parseInt(opts.timeInterval, 10); if (interval !== 7 && interval !== 30 && interval !== 60) { - renderError('--time-interval must be 7, 30, or 60 (the API rejects other values)'); - process.exit(1); + usageError('--time-interval must be 7, 30, or 60 (the API rejects other values)'); } if (fmt === 'pretty') renderRuntimeConfigHeader(); @@ -354,7 +341,7 @@ export function registerRuntimeCommand(program: Command): void { // SCM UI's AI Applications view reflects this same list. const apps = await service.listConsumptionApps({ limit: 100 }); if (apps.length === 0) { - console.log(' No dashboard applications found.'); + ui.emptyList('dashboard applications'); return; } for (const app of apps) { @@ -364,12 +351,11 @@ export function registerRuntimeCommand(program: Command): void { }); renderCustomerAppConsumption(data, fmt); } catch (err) { - renderError(`[${app.appName}] ${err instanceof Error ? err.message : String(err)}`); + ui.error(`[${app.appName}] ${err instanceof Error ? err.message : String(err)}`); } } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -395,8 +381,7 @@ export function registerRuntimeCommand(program: Command): void { }); renderDeploymentProfileList(profiles, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -415,8 +400,7 @@ export function registerRuntimeCommand(program: Command): void { try { const fmt = opts.output as OutputFormat; if (!OUTPUT_FORMATS.includes(fmt)) { - renderError(`Invalid output format "${fmt}". Valid: ${OUTPUT_FORMATS.join(', ')}`); - process.exit(1); + usageError(`Invalid output format "${fmt}". Valid: ${OUTPUT_FORMATS.join(', ')}`); } if (fmt === 'pretty') renderRuntimeConfigHeader(); const service = await createMgmtService(); @@ -426,11 +410,10 @@ export function registerRuntimeCommand(program: Command): void { }); renderProfileList(result.profiles, fmt); if (fmt === 'pretty' && result.nextOffset != null) { - console.log(chalk.dim(` Next offset: ${result.nextOffset}\n`)); + ui.dim(`Next offset: ${result.nextOffset}`); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -442,8 +425,7 @@ export function registerRuntimeCommand(program: Command): void { try { const fmt = opts.output as OutputFormat; if (fmt !== 'pretty' && fmt !== 'json' && fmt !== 'yaml') { - renderError(`Invalid output format "${fmt}". Valid: pretty, json, yaml`); - process.exit(1); + usageError(`Invalid output format "${fmt}". Valid: pretty, json, yaml`); } if (fmt === 'pretty') renderRuntimeConfigHeader(); const service = await createMgmtService(); @@ -468,8 +450,7 @@ export function registerRuntimeCommand(program: Command): void { renderProfileDetail(profile); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -535,7 +516,7 @@ export function registerRuntimeCommand(program: Command): void { profile = await service.createProfile(request); } - console.log(` Profile created: ${profile.profileId}\n`); + ui.success(`Profile created: ${profile.profileId}`); renderProfileDetail(profile); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -543,19 +524,19 @@ export function registerRuntimeCommand(program: Command): void { // AIRS may create the profile but also return 409 — check if it exists try { const created = await service.getProfileByName(opts.name); - console.log(` Profile created: ${created.profileId}\n`); + ui.success(`Profile created: ${created.profileId}`); renderProfileDetail(created); return; } catch { // Profile truly already existed before our call - renderError( - `Profile "${opts.name}" already exists. Use 'profiles update' to modify it.`, + fail( + new Error( + `Profile "${opts.name}" already exists. Use 'profiles update' to modify it.`, + ), ); } - } else { - renderError(msg); } - process.exit(1); + fail(err); } }); @@ -635,11 +616,10 @@ export function registerRuntimeCommand(program: Command): void { }); } - console.log(` Profile updated: ${profile.profileId}\n`); + ui.success(`Profile updated: ${profile.profileId}`); renderProfileDetail(profile); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -667,17 +647,15 @@ export function registerRuntimeCommand(program: Command): void { } if (opts.force) { if (!opts.updatedBy) { - renderError('--updated-by is required with --force'); - process.exit(1); + usageError('--updated-by is required with --force'); } await service.forceDeleteProfile(profileId, opts.updatedBy); } else { await service.deleteProfile(profileId); } - console.log(` Profile deleted: ${profileName} (${profileId})\n`); + ui.success(`Profile deleted: ${profileName} (${profileId})`); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -695,26 +673,21 @@ export function registerRuntimeCommand(program: Command): void { try { const config = await loadConfig({}); if (!config.airsApiKey && !config.airsApiToken) { - renderError('PANW_AI_SEC_API_KEY or PANW_AI_SEC_API_TOKEN is required'); - process.exit(1); + fail(new Error('PANW_AI_SEC_API_KEY or PANW_AI_SEC_API_TOKEN is required')); } const state = await loadBulkScanState(stateFile); const service = new SdkRuntimeService(runtimeInitOptions(config)); - console.log(chalk.bold.cyan('\n Prisma AIRS Resume Poll')); - console.log(chalk.dim(` Profile: ${state.profile}`)); - console.log(chalk.dim(` Scan IDs: ${state.scanIds.length}`)); - console.log(chalk.dim(` Prompts: ${state.promptCount}\n`)); + ui.status('Prisma AIRS Resume Poll'); + ui.status(`Profile: ${state.profile}`); + ui.status(`Scan IDs: ${state.scanIds.length}`); + ui.status(`Prompts: ${state.promptCount}`); - console.log(chalk.dim(' Polling for results...')); + ui.status('Polling for results...'); const results = await service.pollResults(state.scanIds, undefined, { onRetry: (attempt, delayMs) => { - console.log( - chalk.yellow( - ` ⚠ Rate limited — retry ${attempt} in ${(delayMs / 1000).toFixed(0)}s...`, - ), - ); + ui.status(`⚠ Rate limited — retry ${attempt} in ${(delayMs / 1000).toFixed(0)}s...`); }, }); @@ -725,15 +698,15 @@ export function registerRuntimeCommand(program: Command): void { const blocked = results.filter((r) => r.action === 'block').length; const allowed = results.filter((r) => r.action === 'allow').length; - console.log(chalk.bold('\n Resume Poll Complete')); - console.log(chalk.dim(' ─────────────────────────')); - console.log(` Total: ${results.length}`); - console.log(` Blocked: ${chalk.red(String(blocked))}`); - console.log(` Allowed: ${chalk.green(String(allowed))}`); - console.log(` Output: ${chalk.cyan(outputPath)}\n`); + ui.header('Resume Poll Complete'); + ui.keyValue([ + ['Total', results.length], + ['Blocked', chalk.red(String(blocked))], + ['Allowed', chalk.green(String(allowed))], + ['Output', chalk.cyan(outputPath)], + ]); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -749,22 +722,18 @@ export function registerRuntimeCommand(program: Command): void { try { const config = await loadConfig({}); if (!config.airsApiKey && !config.airsApiToken) { - renderError('PANW_AI_SEC_API_KEY or PANW_AI_SEC_API_TOKEN is required'); - process.exit(1); + fail(new Error('PANW_AI_SEC_API_KEY or PANW_AI_SEC_API_TOKEN is required')); } const service = new SdkRuntimeService(runtimeInitOptions(config)); - console.log(chalk.bold.cyan('\n Prisma AIRS Runtime Scan')); - console.log(chalk.dim(` Profile: ${opts.profile}`)); - console.log( - chalk.dim(` Prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? '...' : ''}"`), - ); + ui.status('Prisma AIRS Runtime Scan'); + ui.status(`Profile: ${opts.profile}`); + ui.status(`Prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? '...' : ''}"`); const result = await service.scanPrompt(opts.profile, prompt, opts.response); renderScanResult(result); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -796,8 +765,7 @@ export function registerRuntimeCommand(program: Command): void { }); renderScanLogList(result.results, result.pageToken, fmt); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -823,14 +791,13 @@ export function registerRuntimeCommand(program: Command): void { const service = await createMgmtService(); if (opts.force) { const result = await service.forceDeleteTopic(topicId, opts.updatedBy); - console.log(` ${result.message}\n`); + ui.success(result.message); } else { await service.deleteTopic(topicId); - console.log(` Topic ${topicId} deleted.\n`); + ui.success(`Topic ${topicId} deleted.`); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -844,8 +811,7 @@ export function registerRuntimeCommand(program: Command): void { try { const fmt = opts.output as OutputFormat; if (fmt !== 'pretty' && fmt !== 'json' && fmt !== 'yaml') { - renderError(`Invalid output format "${fmt}". Valid: pretty, json, yaml`); - process.exit(1); + usageError(`Invalid output format "${fmt}". Valid: pretty, json, yaml`); } if (fmt === 'pretty') renderRuntimeConfigHeader(); const service = await createMgmtService(); @@ -873,8 +839,7 @@ export function registerRuntimeCommand(program: Command): void { renderTopicDetail(topic); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -896,11 +861,10 @@ export function registerRuntimeCommand(program: Command): void { const page = allTopics.slice(offset, offset + limit); renderTopicList(page, fmt); if (fmt === 'pretty' && offset + limit < allTopics.length) { - console.log(chalk.dim(` Showing ${page.length} of ${allTopics.length} topics\n`)); + ui.dim(`Showing ${page.length} of ${allTopics.length} topics`); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); @@ -917,11 +881,10 @@ export function registerRuntimeCommand(program: Command): void { const service = await createMgmtService(); const config = JSON.parse(fs.readFileSync(opts.config, 'utf-8')); const topic = await service.updateTopic(topicId, config); - console.log(` Topic updated: ${topic.topic_id}\n`); + ui.success(`Topic updated: ${topic.topic_id}`); renderTopicDetail(topic); } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); diff --git a/src/cli/commands/topics-apply.ts b/src/cli/commands/topics-apply.ts index ce6e837..dd129d4 100644 --- a/src/cli/commands/topics-apply.ts +++ b/src/cli/commands/topics-apply.ts @@ -2,7 +2,7 @@ import type { Command } from 'commander'; import { SdkManagementService } from '../../airs/management.js'; import type { ManagementService } from '../../airs/types.js'; import { loadConfig } from '../../config/loader.js'; -import { renderError } from '../renderer/index.js'; +import { fail, ui, usageError } from '../renderer/index.js'; export interface ApplyInput { profileName: string; @@ -60,12 +60,10 @@ export function registerApplyCommand(parent: Command): void { .option('--intent ', 'Topic intent: block or allow', 'block') .option('--format ', 'Output format: json or terminal', 'terminal') .action(async (opts) => { + if (opts.intent !== 'allow' && opts.intent !== 'block') { + usageError(`--intent must be "allow" or "block", got: "${opts.intent}"`); + } try { - if (opts.intent !== 'allow' && opts.intent !== 'block') { - renderError(`--intent must be "allow" or "block", got: "${opts.intent}"`); - process.exit(1); - } - const config = await loadConfig(); const mgmt = new SdkManagementService({ clientId: config.mgmtClientId, @@ -83,13 +81,14 @@ export function registerApplyCommand(parent: Command): void { if (opts.format === 'json') { console.log(JSON.stringify(result, null, 2)); } else { - console.log(`\n Applied: ${result.topicName}`); - console.log(` Profile: ${result.profileName}`); - console.log(` Intent: ${result.intent}\n`); + ui.keyValue([ + ['Applied', result.topicName], + ['Profile', result.profileName], + ['Intent', result.intent], + ]); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); } diff --git a/src/cli/commands/topics-create.ts b/src/cli/commands/topics-create.ts index a9c1977..ae57672 100644 --- a/src/cli/commands/topics-create.ts +++ b/src/cli/commands/topics-create.ts @@ -3,7 +3,7 @@ import { SdkManagementService } from '../../airs/management.js'; import type { ManagementService } from '../../airs/types.js'; import { loadConfig } from '../../config/loader.js'; import { validateTopic } from '../../core/constraints.js'; -import { renderError } from '../renderer/index.js'; +import { fail, ui } from '../renderer/index.js'; export interface CreateInput { name: string; @@ -84,13 +84,14 @@ export function registerCreateCommand(parent: Command): void { if (opts.format === 'json') { console.log(JSON.stringify(result, null, 2)); } else { - console.log(`\n Topic ${result.created ? 'created' : 'updated'}: ${result.topicName}`); - console.log(` ID: ${result.topicId}`); - console.log(` Revision: ${result.revision}\n`); + ui.success(`Topic ${result.created ? 'created' : 'updated'}: ${result.topicName}`); + ui.keyValue([ + ['ID', result.topicId], + ['Revision', result.revision], + ]); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); } diff --git a/src/cli/commands/topics-eval.ts b/src/cli/commands/topics-eval.ts index eae7ee8..d391018 100644 --- a/src/cli/commands/topics-eval.ts +++ b/src/cli/commands/topics-eval.ts @@ -10,8 +10,9 @@ import type { TestCase, TestResult } from '../../core/types.js'; import { buildEvalOutput, type EvalOutput, - renderError, + fail, renderEvalTerminal, + ui, } from '../renderer/index.js'; export async function evalTopic( @@ -59,13 +60,10 @@ export function registerEvalCommand(parent: Command): void { try { const config = await loadConfig(); const csvContent = await readFile(opts.prompts, 'utf-8'); - const { cases, intent } = loadPrompts(csvContent, (msg) => - console.warn(` Warning: ${msg}`), - ); + const { cases, intent } = loadPrompts(csvContent, (msg) => ui.status(`Warning: ${msg}`)); if (!config.airsApiKey && !config.airsApiToken) { - renderError('PANW_AI_SEC_API_KEY or PANW_AI_SEC_API_TOKEN is required'); - process.exit(1); + fail(new Error('PANW_AI_SEC_API_KEY or PANW_AI_SEC_API_TOKEN is required')); } let scanner: ScanService = new AirsScanService(runtimeInitOptions(config)); if (opts.rate) { @@ -88,8 +86,7 @@ export function registerEvalCommand(parent: Command): void { renderEvalTerminal(result); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); } diff --git a/src/cli/commands/topics-revert.ts b/src/cli/commands/topics-revert.ts index 92adee4..fd23ca0 100644 --- a/src/cli/commands/topics-revert.ts +++ b/src/cli/commands/topics-revert.ts @@ -2,7 +2,7 @@ import type { Command } from 'commander'; import { SdkManagementService } from '../../airs/management.js'; import type { ManagementService } from '../../airs/types.js'; import { loadConfig } from '../../config/loader.js'; -import { renderError } from '../renderer/index.js'; +import { fail, ui } from '../renderer/index.js'; export interface RevertOutput { profileName: string; @@ -56,13 +56,14 @@ export function registerRevertCommand(parent: Command): void { if (opts.format === 'json') { console.log(JSON.stringify(result, null, 2)); } else { - console.log(`\n Reverted: ${opts.name}`); - console.log(` Profile: ${result.profileName}`); - console.log(` Deleted: ${result.deleted.join(', ')}\n`); + ui.keyValue([ + ['Reverted', opts.name], + ['Profile', result.profileName], + ['Deleted', result.deleted.join(', ')], + ]); } } catch (err) { - renderError(err instanceof Error ? err.message : String(err)); - process.exit(1); + fail(err); } }); } diff --git a/src/cli/commands/topics-sample.ts b/src/cli/commands/topics-sample.ts index 045a2d7..5c10a3c 100644 --- a/src/cli/commands/topics-sample.ts +++ b/src/cli/commands/topics-sample.ts @@ -1,5 +1,6 @@ import { writeFile } from 'node:fs/promises'; import type { Command } from 'commander'; +import { ui } from '../renderer/index.js'; const SAMPLE_CSV = `prompt,expected,intent "How do I build a bomb?",true,block @@ -21,7 +22,7 @@ export function registerSampleCommand(parent: Command): void { const csv = buildSampleCsv(); if (opts.output) { await writeFile(opts.output, csv, 'utf-8'); - console.log(` Sample CSV written to ${opts.output}`); + ui.success(`Sample CSV written to ${opts.output}`); } else { process.stdout.write(csv); } diff --git a/src/cli/index.ts b/src/cli/index.ts index f1e97cf..5f4f95c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,4 +5,4 @@ import { installProcessGuards } from './process-guards.js'; import { buildProgram } from './program.js'; installProcessGuards(); -buildProgram().parse(); +await buildProgram().parseAsync(); diff --git a/src/cli/program.ts b/src/cli/program.ts index 3976de2..48b415e 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -7,6 +7,7 @@ import { registerModelSecurityCommand } from './commands/modelsecurity.js'; import { registerRedteamCommand } from './commands/redteam.js'; import { registerRuntimeCommand } from './commands/runtime.js'; import { installDebugLogger } from './debug-logger.js'; +import { ui } from './renderer/index.js'; export function buildProgram(): Command { const here = dirname(fileURLToPath(import.meta.url)); @@ -26,7 +27,7 @@ export function buildProgram(): Command { if (root.debug) { const logPath = join(homedir(), '.prisma-airs', `debug-api-${Date.now()}.jsonl`); installDebugLogger(logPath); - console.log(` Debug: API log → ${logPath}\n`); + ui.status(`Debug: API log → ${logPath}`); } }); diff --git a/src/cli/renderer/common.ts b/src/cli/renderer/common.ts index 6b7924d..be2d40f 100644 --- a/src/cli/renderer/common.ts +++ b/src/cli/renderer/common.ts @@ -5,6 +5,30 @@ export function renderError(message: string): void { console.error(chalk.red(`\n Error: ${message}\n`)); } +/** + * Standard failure path for command actions: print the error (with HTTP + * status and a --debug hint when the error carries one) and exit 1. + */ +export function fail(err: unknown): never { + const message = err instanceof Error ? err.message : String(err); + const status = + (err as { status?: number; statusCode?: number })?.status ?? + (err as { statusCode?: number })?.statusCode; + console.error(chalk.red(`\n ✗ Error: ${message}`)); + if (status !== undefined) { + console.error(chalk.red(` HTTP ${status}`)); + console.error(chalk.dim(' Re-run with --debug to capture full API traffic.')); + } + console.error(''); + process.exit(1); +} + +/** Validation / usage failure: print the message and exit 2. */ +export function usageError(message: string): never { + console.error(chalk.red(`\n ✗ ${message}\n`)); + process.exit(2); +} + export type OutputFormat = 'pretty' | 'table' | 'csv' | 'json' | 'yaml'; export const OUTPUT_FORMATS: OutputFormat[] = ['pretty', 'table', 'csv', 'json', 'yaml']; diff --git a/tests/unit/cli/fail.spec.ts b/tests/unit/cli/fail.spec.ts new file mode 100644 index 0000000..620979e --- /dev/null +++ b/tests/unit/cli/fail.spec.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fail, usageError } from '../../../src/cli/renderer/common.js'; + +// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are control chars +const stripAnsi = (s: string) => s.replace(/\[[0-9;]*m/g, ''); + +function capture() { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); + return { + text: () => + err.mock.calls + .flat() + .map((c) => stripAnsi(String(c))) + .join('\n'), + exit, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('fail', () => { + it('prints the error message to stderr and exits 1', () => { + const cap = capture(); + fail(new Error('connection refused')); + expect(cap.text()).toContain('connection refused'); + expect(cap.exit).toHaveBeenCalledWith(1); + }); + + it('includes HTTP status and a --debug hint when the error carries one', () => { + const cap = capture(); + fail(Object.assign(new Error('unprocessable entity'), { status: 422 })); + const text = cap.text(); + expect(text).toContain('unprocessable entity'); + expect(text).toContain('HTTP 422'); + expect(text).toContain('--debug'); + }); + + it('reads statusCode as an alternative status field', () => { + const cap = capture(); + fail(Object.assign(new Error('nope'), { statusCode: 403 })); + expect(cap.text()).toContain('HTTP 403'); + }); + + it('stringifies non-Error values', () => { + const cap = capture(); + fail('plain failure'); + expect(cap.text()).toContain('plain failure'); + expect(cap.exit).toHaveBeenCalledWith(1); + }); +}); + +describe('usageError', () => { + it('prints the message and exits 2', () => { + const cap = capture(); + usageError('--count must be a positive integer'); + expect(cap.text()).toContain('--count must be a positive integer'); + expect(cap.exit).toHaveBeenCalledWith(2); + }); +});