From 15c29d672868d2ef4e8b4462f4d02b40ec966cde Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 27 May 2026 20:04:58 -0500 Subject: [PATCH] fix(redteam): add --output flag on prompt-sets get and properties values (#198) --- ...team-output-flag-prompt-sets-properties.md | 5 + docs/cli/redteam/prompt-sets.md | 6 ++ docs/cli/redteam/properties.md | 6 ++ src/cli/commands/redteam.ts | 22 +++-- src/cli/renderer/redteam.ts | 49 ++++++++-- tests/unit/cli/redteam-output-flag.spec.ts | 95 +++++++++++++++++++ 6 files changed, 166 insertions(+), 17 deletions(-) create mode 100644 .changeset/fix-redteam-output-flag-prompt-sets-properties.md create mode 100644 tests/unit/cli/redteam-output-flag.spec.ts diff --git a/.changeset/fix-redteam-output-flag-prompt-sets-properties.md b/.changeset/fix-redteam-output-flag-prompt-sets-properties.md new file mode 100644 index 0000000..55b9788 --- /dev/null +++ b/.changeset/fix-redteam-output-flag-prompt-sets-properties.md @@ -0,0 +1,5 @@ +--- +"@cdot65/prisma-airs-cli": patch +--- + +Add `--output ` to `airs redteam prompt-sets get` and `airs redteam properties values`. Both previously only emitted the pretty renderer, blocking `jq`-pipe workflows and the docs sidecar's pretty+json+yaml triplet requirement. diff --git a/docs/cli/redteam/prompt-sets.md b/docs/cli/redteam/prompt-sets.md index f09e965..8434e4c 100644 --- a/docs/cli/redteam/prompt-sets.md +++ b/docs/cli/redteam/prompt-sets.md @@ -47,6 +47,12 @@ airs redteam prompt-sets get [options] - `uuid` (required) — +#### Options + +| Flag | Required | Default | Description | +|------|:--------:|---------|-------------| +| `--output ` | No | `pretty` | Output format: pretty, json, yaml | + #### Examples !!! warning "Example needed" diff --git a/docs/cli/redteam/properties.md b/docs/cli/redteam/properties.md index 7e093a1..7ddb88c 100644 --- a/docs/cli/redteam/properties.md +++ b/docs/cli/redteam/properties.md @@ -64,6 +64,12 @@ airs redteam properties values [options] - `name` (required) — +#### Options + +| Flag | Required | Default | Description | +|------|:--------:|---------|-------------| +| `--output ` | No | `pretty` | Output format: pretty, json, yaml | + #### Examples !!! warning "Example needed" diff --git a/src/cli/commands/redteam.ts b/src/cli/commands/redteam.ts index 7c5f6e1..a44ea4e 100644 --- a/src/cli/commands/redteam.ts +++ b/src/cli/commands/redteam.ts @@ -433,14 +433,20 @@ export function registerRedteamCommand(program: Command): void { promptSets .command('get ') .description('Get prompt set details') - .action(async (uuid: string) => { + .option('--output ', 'Output format: pretty, json, yaml', 'pretty') + .action(async (uuid: string, opts) => { try { - renderRedteamHeader(); + const fmt = opts.output as OutputFormat; + if (fmt === 'pretty') renderRedteamHeader(); const service = await createPromptSetService(); const ps = await service.getPromptSet(uuid); - renderPromptSetDetail(ps); const info = await service.getPromptSetVersionInfo(uuid); - renderVersionInfo(info); + if (fmt === 'pretty') { + renderPromptSetDetail(ps); + renderVersionInfo(info); + } else { + renderPromptSetDetail(ps, fmt, info); + } } catch (err) { renderError(err instanceof Error ? err.message : String(err)); process.exit(1); @@ -669,12 +675,14 @@ export function registerRedteamCommand(program: Command): void { properties .command('values ') .description('List values for a property') - .action(async (name: string) => { + .option('--output ', 'Output format: pretty, json, yaml', 'pretty') + .action(async (name: string, opts) => { try { - renderRedteamHeader(); + const fmt = opts.output as OutputFormat; + if (fmt === 'pretty') renderRedteamHeader(); const service = await createPromptSetService(); const values = await service.getPropertyValues(name); - renderPropertyValues(values); + renderPropertyValues(values, fmt); } catch (err) { renderError(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/src/cli/renderer/redteam.ts b/src/cli/renderer/redteam.ts index 6ad5c05..5c940bb 100644 --- a/src/cli/renderer/redteam.ts +++ b/src/cli/renderer/redteam.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import { dump as yamlDump } from 'js-yaml'; import { formatOutput, type OutputFormat } from './common.js'; /** Render the red team banner. */ @@ -400,15 +401,32 @@ export function renderTargetDetail(target: { } /** Render prompt set detail. */ -export function renderPromptSetDetail(ps: { - uuid: string; - name: string; - active: boolean; - archive: boolean; - description?: string; - createdAt?: string; - updatedAt?: string; -}): void { +export function renderPromptSetDetail( + ps: { + uuid: string; + name: string; + active: boolean; + archive: boolean; + description?: string; + createdAt?: string; + updatedAt?: string; + }, + format: OutputFormat = 'pretty', + info?: { + uuid: string; + version: number; + stats: { total: number; active: number; inactive: number }; + }, +): void { + if (format !== 'pretty') { + const payload = info ? { ...ps, versionInfo: info } : { ...ps }; + if (format === 'json') { + console.log(JSON.stringify(payload, null, 2)); + } else if (format === 'yaml') { + console.log(yamlDump(payload)); + } + return; + } console.log(chalk.bold('\n Prompt Set Detail:\n')); console.log(` UUID: ${chalk.dim(ps.uuid)}`); console.log(` Name: ${ps.name}`); @@ -543,7 +561,18 @@ export function renderEulaContent(content: { content: string }): void { } /** Render property values. */ -export function renderPropertyValues(values: Array<{ name: string; value: string }>): void { +export function renderPropertyValues( + values: Array<{ name: string; value: string }>, + format: OutputFormat = 'pretty', +): void { + if (format !== 'pretty') { + if (format === 'json') { + console.log(JSON.stringify(values, null, 2)); + } else if (format === 'yaml') { + console.log(yamlDump(values)); + } + return; + } if (values.length === 0) { console.log(chalk.dim(' No property values found.\n')); return; diff --git a/tests/unit/cli/redteam-output-flag.spec.ts b/tests/unit/cli/redteam-output-flag.spec.ts new file mode 100644 index 0000000..f6507e9 --- /dev/null +++ b/tests/unit/cli/redteam-output-flag.spec.ts @@ -0,0 +1,95 @@ +import { load as yamlLoad } from 'js-yaml'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +let output: string[]; +const originalLog = console.log; + +beforeEach(() => { + output = []; + console.log = (...args: unknown[]) => output.push(args.join(' ')); +}); + +afterEach(() => { + console.log = originalLog; +}); + +const samplePromptSet = { + uuid: 'ps-uuid-001', + name: 'sample-set', + active: true, + archive: false, + description: 'sample description', + createdAt: '2026-01-02T03:04:05Z', + updatedAt: '2026-01-02T03:04:05Z', +}; + +const sampleVersionInfo = { + uuid: 'ps-uuid-001', + version: 7, + stats: { total: 42, active: 40, inactive: 2 }, +}; + +const sampleValues = [ + { name: 'persona', value: 'pirate' }, + { name: 'persona', value: 'doctor' }, +]; + +describe('renderPromptSetDetail --output', () => { + it('emits JSON with combined detail + versionInfo when format=json', async () => { + const { renderPromptSetDetail } = await import('../../../src/cli/renderer/redteam.js'); + renderPromptSetDetail(samplePromptSet, 'json', sampleVersionInfo); + const parsed = JSON.parse(output.join('\n')); + expect(parsed.uuid).toBe('ps-uuid-001'); + expect(parsed.name).toBe('sample-set'); + expect(parsed.versionInfo.version).toBe(7); + expect(parsed.versionInfo.stats.total).toBe(42); + }); + + it('emits YAML with combined detail + versionInfo when format=yaml', async () => { + const { renderPromptSetDetail } = await import('../../../src/cli/renderer/redteam.js'); + renderPromptSetDetail(samplePromptSet, 'yaml', sampleVersionInfo); + const parsed = yamlLoad(output.join('\n')) as Record; + expect(parsed.uuid).toBe('ps-uuid-001'); + expect(parsed.name).toBe('sample-set'); + expect((parsed.versionInfo as { version: number }).version).toBe(7); + }); + + it('still renders pretty form when format=pretty', async () => { + const { renderPromptSetDetail } = await import('../../../src/cli/renderer/redteam.js'); + renderPromptSetDetail(samplePromptSet, 'pretty'); + const text = output.join('\n'); + expect(text).toContain('Prompt Set Detail'); + expect(text).toContain('sample-set'); + }); +}); + +describe('renderPropertyValues --output', () => { + it('emits JSON array when format=json', async () => { + const { renderPropertyValues } = await import('../../../src/cli/renderer/redteam.js'); + renderPropertyValues(sampleValues, 'json'); + const parsed = JSON.parse(output.join('\n')); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toEqual(sampleValues); + }); + + it('emits YAML when format=yaml', async () => { + const { renderPropertyValues } = await import('../../../src/cli/renderer/redteam.js'); + renderPropertyValues(sampleValues, 'yaml'); + const parsed = yamlLoad(output.join('\n')); + expect(parsed).toEqual(sampleValues); + }); + + it('still renders pretty form when format=pretty', async () => { + const { renderPropertyValues } = await import('../../../src/cli/renderer/redteam.js'); + renderPropertyValues(sampleValues, 'pretty'); + const text = output.join('\n'); + expect(text).toContain('Property Values'); + expect(text).toContain('persona'); + }); + + it('prints empty-state message for empty list in pretty mode', async () => { + const { renderPropertyValues } = await import('../../../src/cli/renderer/redteam.js'); + renderPropertyValues([], 'pretty'); + expect(output.join('\n')).toContain('No property values'); + }); +});