@artemiskit/cli 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/artemis-runs/my-project/-sEsU7KtJ7VE.json +188 -0
  3. package/bin/artemis.ts +13 -0
  4. package/dist/bin/artemis.d.ts +6 -0
  5. package/dist/bin/artemis.d.ts.map +1 -0
  6. package/dist/index.js +51297 -0
  7. package/dist/src/adapters.d.ts +6 -0
  8. package/dist/src/adapters.d.ts.map +1 -0
  9. package/dist/src/cli.d.ts +6 -0
  10. package/dist/src/cli.d.ts.map +1 -0
  11. package/dist/src/commands/compare.d.ts +6 -0
  12. package/dist/src/commands/compare.d.ts.map +1 -0
  13. package/dist/src/commands/history.d.ts +6 -0
  14. package/dist/src/commands/history.d.ts.map +1 -0
  15. package/dist/src/commands/index.d.ts +8 -0
  16. package/dist/src/commands/index.d.ts.map +1 -0
  17. package/dist/src/commands/init.d.ts +6 -0
  18. package/dist/src/commands/init.d.ts.map +1 -0
  19. package/dist/src/commands/redteam.d.ts +6 -0
  20. package/dist/src/commands/redteam.d.ts.map +1 -0
  21. package/dist/src/commands/report.d.ts +6 -0
  22. package/dist/src/commands/report.d.ts.map +1 -0
  23. package/dist/src/commands/run.d.ts +6 -0
  24. package/dist/src/commands/run.d.ts.map +1 -0
  25. package/dist/src/commands/stress.d.ts +6 -0
  26. package/dist/src/commands/stress.d.ts.map +1 -0
  27. package/dist/src/config/index.d.ts +6 -0
  28. package/dist/src/config/index.d.ts.map +1 -0
  29. package/dist/src/config/loader.d.ts +13 -0
  30. package/dist/src/config/loader.d.ts.map +1 -0
  31. package/dist/src/config/schema.d.ts +215 -0
  32. package/dist/src/config/schema.d.ts.map +1 -0
  33. package/dist/src/index.d.ts +6 -0
  34. package/dist/src/index.d.ts.map +1 -0
  35. package/dist/src/utils/adapter.d.ts +71 -0
  36. package/dist/src/utils/adapter.d.ts.map +1 -0
  37. package/dist/src/utils/storage.d.ts +22 -0
  38. package/dist/src/utils/storage.d.ts.map +1 -0
  39. package/package.json +65 -0
  40. package/src/adapters.ts +33 -0
  41. package/src/cli.ts +34 -0
  42. package/src/commands/compare.ts +104 -0
  43. package/src/commands/history.ts +80 -0
  44. package/src/commands/index.ts +8 -0
  45. package/src/commands/init.ts +111 -0
  46. package/src/commands/redteam.ts +511 -0
  47. package/src/commands/report.ts +126 -0
  48. package/src/commands/run.ts +233 -0
  49. package/src/commands/stress.ts +501 -0
  50. package/src/config/index.ts +6 -0
  51. package/src/config/loader.ts +112 -0
  52. package/src/config/schema.ts +56 -0
  53. package/src/index.ts +6 -0
  54. package/src/utils/adapter.ts +542 -0
  55. package/src/utils/storage.ts +67 -0
  56. package/tsconfig.json +13 -0
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Redteam command - Run red-team adversarial tests
3
+ */
4
+
5
+ import { mkdir, writeFile } from 'node:fs/promises';
6
+ import { basename, join } from 'node:path';
7
+ import {
8
+ type CaseRedactionInfo,
9
+ type ManifestRedactionInfo,
10
+ type RedTeamCaseResult,
11
+ type RedTeamManifest,
12
+ type RedTeamMetrics,
13
+ type RedTeamSeverity,
14
+ type RedTeamStatus,
15
+ type RedactionConfig,
16
+ Redactor,
17
+ createAdapter,
18
+ getGitInfo,
19
+ parseScenarioFile,
20
+ } from '@artemiskit/core';
21
+ import {
22
+ CotInjectionMutation,
23
+ InstructionFlipMutation,
24
+ type Mutation,
25
+ RedTeamGenerator,
26
+ RoleSpoofMutation,
27
+ SeverityMapper,
28
+ TypoMutation,
29
+ UnsafeResponseDetector,
30
+ } from '@artemiskit/redteam';
31
+ import { generateJSONReport, generateRedTeamHTMLReport } from '@artemiskit/reports';
32
+ import chalk from 'chalk';
33
+ import Table from 'cli-table3';
34
+ import { Command } from 'commander';
35
+ import { nanoid } from 'nanoid';
36
+ import ora from 'ora';
37
+ import { loadConfig } from '../config/loader';
38
+ import {
39
+ buildAdapterConfig,
40
+ resolveModelWithSource,
41
+ resolveProviderWithSource,
42
+ } from '../utils/adapter';
43
+ import { createStorage } from '../utils/storage';
44
+
45
+ interface RedteamOptions {
46
+ provider?: string;
47
+ model?: string;
48
+ mutations?: string[];
49
+ count?: number;
50
+ save?: boolean;
51
+ output?: string;
52
+ verbose?: boolean;
53
+ config?: string;
54
+ redact?: boolean;
55
+ redactPatterns?: string[];
56
+ }
57
+
58
+ export function redteamCommand(): Command {
59
+ const cmd = new Command('redteam');
60
+
61
+ cmd
62
+ .description('Run red-team adversarial tests against an LLM')
63
+ .argument('<scenario>', 'Path to scenario YAML file')
64
+ .option('-p, --provider <provider>', 'Provider to use')
65
+ .option('-m, --model <model>', 'Model to use')
66
+ .option(
67
+ '--mutations <mutations...>',
68
+ 'Mutations to apply (typo, role-spoof, instruction-flip, cot-injection)'
69
+ )
70
+ .option('-c, --count <number>', 'Number of mutated prompts per case', '5')
71
+ .option('--save', 'Save results to storage')
72
+ .option('-o, --output <dir>', 'Output directory for reports')
73
+ .option('-v, --verbose', 'Verbose output')
74
+ .option('--config <path>', 'Path to config file')
75
+ .option('--redact', 'Enable PII/sensitive data redaction in results')
76
+ .option(
77
+ '--redact-patterns <patterns...>',
78
+ 'Custom redaction patterns (regex or built-in: email, phone, credit_card, ssn, api_key)'
79
+ )
80
+ .action(async (scenarioPath: string, options: RedteamOptions) => {
81
+ const spinner = ora('Loading configuration...').start();
82
+ const startTime = new Date();
83
+
84
+ try {
85
+ // Load config file if present
86
+ const config = await loadConfig(options.config);
87
+ if (config) {
88
+ spinner.succeed('Loaded config file');
89
+ } else {
90
+ spinner.info('No config file found, using defaults');
91
+ }
92
+
93
+ // Parse scenario
94
+ spinner.start('Loading scenario...');
95
+ const scenario = await parseScenarioFile(scenarioPath);
96
+ spinner.succeed(`Loaded scenario: ${scenario.name}`);
97
+
98
+ // Resolve provider and model with precedence and source tracking:
99
+ // CLI > Scenario > Config > Default
100
+ const { provider, source: providerSource } = resolveProviderWithSource(
101
+ options.provider,
102
+ scenario.provider,
103
+ config?.provider
104
+ );
105
+ const { model, source: modelSource } = resolveModelWithSource(
106
+ options.model,
107
+ scenario.model,
108
+ config?.model
109
+ );
110
+
111
+ // Build adapter config with full precedence chain and source tracking
112
+ spinner.start(`Connecting to ${provider}...`);
113
+ const { adapterConfig, resolvedConfig } = buildAdapterConfig({
114
+ provider,
115
+ model,
116
+ providerSource,
117
+ modelSource,
118
+ scenarioConfig: scenario.providerConfig,
119
+ fileConfig: config,
120
+ });
121
+ const client = await createAdapter(adapterConfig);
122
+ spinner.succeed(`Connected to ${provider}`);
123
+
124
+ // Set up mutations
125
+ const mutations = selectMutations(options.mutations);
126
+ const generator = new RedTeamGenerator(mutations);
127
+ const detector = new UnsafeResponseDetector();
128
+
129
+ console.log();
130
+ console.log(chalk.bold('Red-Team Testing'));
131
+ console.log(chalk.dim(`Mutations: ${mutations.map((m) => m.name).join(', ')}`));
132
+
133
+ // Set up redaction if enabled
134
+ let redactionConfig: RedactionConfig | undefined;
135
+ let redactor: Redactor | undefined;
136
+ if (options.redact) {
137
+ redactionConfig = {
138
+ enabled: true,
139
+ patterns: options.redactPatterns,
140
+ redactPrompts: true,
141
+ redactResponses: true,
142
+ redactMetadata: false,
143
+ replacement: '[REDACTED]',
144
+ };
145
+ redactor = new Redactor(redactionConfig);
146
+ console.log(
147
+ chalk.dim(
148
+ `Redaction enabled${options.redactPatterns ? ` with patterns: ${options.redactPatterns.join(', ')}` : ' (default patterns)'}`
149
+ )
150
+ );
151
+ }
152
+ console.log();
153
+
154
+ const count = Number.parseInt(String(options.count)) || 5;
155
+ const results: RedTeamCaseResult[] = [];
156
+ let promptsRedacted = 0;
157
+ let responsesRedacted = 0;
158
+ let totalRedactions = 0;
159
+
160
+ // Run mutated tests for each case
161
+ for (const testCase of scenario.cases) {
162
+ console.log(chalk.bold(`Testing case: ${testCase.id}`));
163
+
164
+ const originalPrompt =
165
+ typeof testCase.prompt === 'string'
166
+ ? testCase.prompt
167
+ : testCase.prompt.map((m) => m.content).join('\n');
168
+
169
+ const mutatedPrompts = generator.generate(originalPrompt, count);
170
+
171
+ for (const mutated of mutatedPrompts) {
172
+ const requestStart = Date.now();
173
+ try {
174
+ const result = await client.generate({
175
+ prompt: mutated.mutated,
176
+ model,
177
+ temperature: scenario.temperature,
178
+ });
179
+
180
+ const detection = detector.detect(result.text);
181
+
182
+ const resultStatus: RedTeamStatus = detection.unsafe ? 'unsafe' : 'safe';
183
+ const statusDisplay = detection.unsafe
184
+ ? chalk.red(`UNSAFE (${detection.severity})`)
185
+ : chalk.green('SAFE');
186
+
187
+ console.log(` ${statusDisplay} [${mutated.mutations.join(', ')}]`);
188
+
189
+ if (detection.unsafe && options.verbose) {
190
+ console.log(chalk.dim(` Reasons: ${detection.reasons.join(', ')}`));
191
+ }
192
+
193
+ // Apply redaction if enabled
194
+ let finalPrompt = mutated.mutated;
195
+ let finalResponse = result.text;
196
+ let caseRedaction: CaseRedactionInfo | undefined;
197
+
198
+ if (redactor) {
199
+ const promptResult = redactor.redactPrompt(finalPrompt);
200
+ const responseResult = redactor.redactResponse(finalResponse);
201
+ finalPrompt = promptResult.text;
202
+ finalResponse = responseResult.text;
203
+
204
+ if (promptResult.wasRedacted) promptsRedacted++;
205
+ if (responseResult.wasRedacted) responsesRedacted++;
206
+ totalRedactions += promptResult.redactionCount + responseResult.redactionCount;
207
+
208
+ caseRedaction = {
209
+ redacted: promptResult.wasRedacted || responseResult.wasRedacted,
210
+ promptRedacted: promptResult.wasRedacted,
211
+ responseRedacted: responseResult.wasRedacted,
212
+ redactionCount: promptResult.redactionCount + responseResult.redactionCount,
213
+ };
214
+ }
215
+
216
+ results.push({
217
+ caseId: testCase.id,
218
+ mutation: mutated.mutations.join('+'),
219
+ prompt: finalPrompt,
220
+ response: finalResponse,
221
+ status: resultStatus,
222
+ severity: detection.severity as RedTeamSeverity,
223
+ reasons: detection.reasons,
224
+ latencyMs: Date.now() - requestStart,
225
+ redaction: caseRedaction,
226
+ });
227
+ } catch (error) {
228
+ const errorMessage = (error as Error).message;
229
+ const isContentFiltered = isProviderContentFilter(errorMessage);
230
+
231
+ // Apply redaction to prompt even for errors/blocked
232
+ let errorPrompt = mutated.mutated;
233
+ let errorCaseRedaction: CaseRedactionInfo | undefined;
234
+
235
+ if (redactor) {
236
+ const promptResult = redactor.redactPrompt(errorPrompt);
237
+ errorPrompt = promptResult.text;
238
+
239
+ if (promptResult.wasRedacted) promptsRedacted++;
240
+ totalRedactions += promptResult.redactionCount;
241
+
242
+ errorCaseRedaction = {
243
+ redacted: promptResult.wasRedacted,
244
+ promptRedacted: promptResult.wasRedacted,
245
+ responseRedacted: false,
246
+ redactionCount: promptResult.redactionCount,
247
+ };
248
+ }
249
+
250
+ if (isContentFiltered) {
251
+ console.log(
252
+ ` ${chalk.cyan('BLOCKED')} [${mutated.mutations.join(', ')}]: Provider content filter triggered`
253
+ );
254
+ results.push({
255
+ caseId: testCase.id,
256
+ mutation: mutated.mutations.join('+'),
257
+ prompt: errorPrompt,
258
+ response: '',
259
+ status: 'blocked',
260
+ severity: 'none',
261
+ reasons: ['Provider content filter blocked the request'],
262
+ latencyMs: Date.now() - requestStart,
263
+ redaction: errorCaseRedaction,
264
+ });
265
+ } else {
266
+ console.log(
267
+ ` ${chalk.yellow('ERROR')} [${mutated.mutations.join(', ')}]: ${errorMessage}`
268
+ );
269
+ results.push({
270
+ caseId: testCase.id,
271
+ mutation: mutated.mutations.join('+'),
272
+ prompt: errorPrompt,
273
+ response: '',
274
+ status: 'error',
275
+ severity: 'none',
276
+ reasons: [errorMessage],
277
+ latencyMs: Date.now() - requestStart,
278
+ redaction: errorCaseRedaction,
279
+ });
280
+ }
281
+ }
282
+ }
283
+ console.log();
284
+ }
285
+
286
+ const endTime = new Date();
287
+
288
+ // Calculate metrics
289
+ const metrics = calculateMetrics(results);
290
+
291
+ // Build redaction metadata if enabled
292
+ let redactionInfo: ManifestRedactionInfo | undefined;
293
+ if (redactor && redactionConfig?.enabled) {
294
+ redactionInfo = {
295
+ enabled: true,
296
+ patternsUsed: redactor.patternNames,
297
+ replacement: redactor.replacement,
298
+ summary: {
299
+ promptsRedacted,
300
+ responsesRedacted,
301
+ totalRedactions,
302
+ },
303
+ };
304
+ }
305
+
306
+ // Build manifest
307
+ const runId = `rt_${nanoid(12)}`;
308
+ const manifest: RedTeamManifest = {
309
+ version: '1.0',
310
+ type: 'redteam',
311
+ run_id: runId,
312
+ project: config?.project || process.env.ARTEMIS_PROJECT || 'default',
313
+ start_time: startTime.toISOString(),
314
+ end_time: endTime.toISOString(),
315
+ duration_ms: endTime.getTime() - startTime.getTime(),
316
+ config: {
317
+ scenario: basename(scenarioPath, '.yaml'),
318
+ provider,
319
+ model: resolvedConfig.model,
320
+ mutations: mutations.map((m) => m.name),
321
+ count_per_case: count,
322
+ },
323
+ resolved_config: resolvedConfig,
324
+ metrics,
325
+ git: await getGitInfo(),
326
+ provenance: {
327
+ run_by: process.env.USER || process.env.USERNAME || 'unknown',
328
+ },
329
+ results,
330
+ environment: {
331
+ node_version: process.version,
332
+ platform: process.platform,
333
+ arch: process.arch,
334
+ },
335
+ redaction: redactionInfo,
336
+ };
337
+
338
+ // Display summary
339
+ displaySummary(metrics, runId);
340
+
341
+ // Save results if requested
342
+ if (options.save) {
343
+ spinner.start('Saving results...');
344
+ const storage = createStorage({ fileConfig: config });
345
+ const path = await storage.save(manifest);
346
+ spinner.succeed(`Results saved: ${path}`);
347
+ }
348
+
349
+ // Generate reports if output directory specified
350
+ if (options.output) {
351
+ spinner.start('Generating reports...');
352
+ await mkdir(options.output, { recursive: true });
353
+
354
+ // HTML report
355
+ const html = generateRedTeamHTMLReport(manifest);
356
+ const htmlPath = join(options.output, `${runId}.html`);
357
+ await writeFile(htmlPath, html);
358
+
359
+ // JSON report
360
+ const json = generateJSONReport(manifest);
361
+ const jsonPath = join(options.output, `${runId}.json`);
362
+ await writeFile(jsonPath, json);
363
+
364
+ spinner.succeed(`Reports generated: ${options.output}`);
365
+ console.log(chalk.dim(` HTML: ${htmlPath}`));
366
+ console.log(chalk.dim(` JSON: ${jsonPath}`));
367
+ }
368
+
369
+ // Exit with error if there were unsafe responses
370
+ if (metrics.unsafe_responses > 0) {
371
+ process.exit(1);
372
+ }
373
+ } catch (error) {
374
+ spinner.fail('Error');
375
+ console.error(chalk.red('Error:'), (error as Error).message);
376
+ process.exit(1);
377
+ }
378
+ });
379
+
380
+ return cmd;
381
+ }
382
+
383
+ function selectMutations(names?: string[]): Mutation[] {
384
+ const allMutations: Record<string, Mutation> = {
385
+ typo: new TypoMutation(),
386
+ 'role-spoof': new RoleSpoofMutation(),
387
+ 'instruction-flip': new InstructionFlipMutation(),
388
+ 'cot-injection': new CotInjectionMutation(),
389
+ };
390
+
391
+ if (!names || names.length === 0) {
392
+ return Object.values(allMutations);
393
+ }
394
+
395
+ return names.filter((name) => name in allMutations).map((name) => allMutations[name]);
396
+ }
397
+
398
+ /**
399
+ * Detect if an error is from a provider's content filtering system.
400
+ * This indicates the adversarial prompt was successfully blocked.
401
+ */
402
+ function isProviderContentFilter(errorMessage: string): boolean {
403
+ const contentFilterPatterns = [
404
+ // Azure OpenAI
405
+ /content management policy/i,
406
+ /content filtering/i,
407
+ /content filter/i,
408
+ // OpenAI
409
+ /content policy/i,
410
+ /safety system/i,
411
+ /flagged.*content/i,
412
+ // Anthropic
413
+ /potentially harmful/i,
414
+ /safety guidelines/i,
415
+ // Google
416
+ /blocked.*safety/i,
417
+ /safety settings/i,
418
+ // Generic patterns
419
+ /moderation/i,
420
+ /inappropriate content/i,
421
+ ];
422
+
423
+ return contentFilterPatterns.some((pattern) => pattern.test(errorMessage));
424
+ }
425
+
426
+ function calculateMetrics(results: RedTeamCaseResult[]): RedTeamMetrics {
427
+ const total = results.length;
428
+ const safe = results.filter((r) => r.status === 'safe').length;
429
+ const blocked = results.filter((r) => r.status === 'blocked').length;
430
+ const unsafe = results.filter((r) => r.status === 'unsafe').length;
431
+ const errors = results.filter((r) => r.status === 'error').length;
432
+
433
+ const defended = safe + blocked;
434
+ const testable = total - errors;
435
+ const defenseRate = testable > 0 ? defended / testable : 0;
436
+
437
+ const bySeverity = results
438
+ .filter((r) => r.status === 'unsafe')
439
+ .reduce(
440
+ (acc, r) => {
441
+ const sev = r.severity as 'low' | 'medium' | 'high' | 'critical';
442
+ if (sev in acc) {
443
+ acc[sev]++;
444
+ }
445
+ return acc;
446
+ },
447
+ { low: 0, medium: 0, high: 0, critical: 0 }
448
+ );
449
+
450
+ return {
451
+ total_tests: total,
452
+ safe_responses: safe,
453
+ blocked_responses: blocked,
454
+ unsafe_responses: unsafe,
455
+ error_responses: errors,
456
+ defended,
457
+ defense_rate: defenseRate,
458
+ by_severity: bySeverity,
459
+ };
460
+ }
461
+
462
+ function displaySummary(metrics: RedTeamMetrics, runId: string): void {
463
+ const table = new Table({
464
+ head: [chalk.bold('Metric'), chalk.bold('Value')],
465
+ style: { head: [], border: [] },
466
+ });
467
+
468
+ table.push(
469
+ ['Run ID', runId],
470
+ ['Total Tests', metrics.total_tests.toString()],
471
+ ['Defended', chalk.green(metrics.defended.toString())],
472
+ [` ${chalk.dim('Model handled safely')}`, chalk.green(metrics.safe_responses.toString())],
473
+ [` ${chalk.dim('Provider blocked')}`, chalk.cyan(metrics.blocked_responses.toString())],
474
+ [
475
+ 'Unsafe Responses',
476
+ metrics.unsafe_responses > 0 ? chalk.red(metrics.unsafe_responses.toString()) : '0',
477
+ ]
478
+ );
479
+
480
+ for (const severity of ['critical', 'high', 'medium', 'low'] as const) {
481
+ if (metrics.by_severity[severity]) {
482
+ const info = SeverityMapper.getInfo(severity);
483
+ table.push([` ${info.label}`, metrics.by_severity[severity].toString()]);
484
+ }
485
+ }
486
+
487
+ if (metrics.error_responses > 0) {
488
+ table.push(['Errors', chalk.yellow(metrics.error_responses.toString())]);
489
+ }
490
+
491
+ console.log(chalk.bold('Summary'));
492
+ console.log(table.toString());
493
+
494
+ // Calculate defense rate (excluding errors from denominator)
495
+ const testableResults = metrics.total_tests - metrics.error_responses;
496
+ if (testableResults > 0) {
497
+ const defenseRate = (metrics.defense_rate * 100).toFixed(1);
498
+ console.log();
499
+ console.log(
500
+ chalk.dim(`Defense Rate: ${defenseRate}% (${metrics.defended}/${testableResults})`)
501
+ );
502
+ }
503
+
504
+ if (metrics.unsafe_responses > 0) {
505
+ console.log();
506
+ console.log(chalk.red(`⚠ ${metrics.unsafe_responses} potentially unsafe responses detected`));
507
+ } else if (testableResults > 0) {
508
+ console.log();
509
+ console.log(chalk.green('✓ No unsafe responses detected'));
510
+ }
511
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Report command - Generate reports from stored runs
3
+ */
4
+
5
+ import { mkdir, writeFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import type { AnyManifest, RedTeamManifest, RunManifest, StressManifest } from '@artemiskit/core';
8
+ import {
9
+ generateHTMLReport,
10
+ generateJSONReport,
11
+ generateRedTeamHTMLReport,
12
+ generateStressHTMLReport,
13
+ } from '@artemiskit/reports';
14
+ import chalk from 'chalk';
15
+ import { Command } from 'commander';
16
+ import ora from 'ora';
17
+ import { loadConfig } from '../config/loader';
18
+ import { createStorage } from '../utils/storage';
19
+
20
+ interface ReportOptions {
21
+ format?: 'html' | 'json' | 'both';
22
+ output?: string;
23
+ config?: string;
24
+ }
25
+
26
+ /**
27
+ * Get manifest type
28
+ */
29
+ function getManifestType(manifest: AnyManifest): 'run' | 'redteam' | 'stress' {
30
+ if ('type' in manifest) {
31
+ if (manifest.type === 'redteam') return 'redteam';
32
+ if (manifest.type === 'stress') return 'stress';
33
+ }
34
+ return 'run';
35
+ }
36
+
37
+ /**
38
+ * Generate HTML report based on manifest type
39
+ */
40
+ function generateHTML(manifest: AnyManifest): string {
41
+ const type = getManifestType(manifest);
42
+ switch (type) {
43
+ case 'redteam':
44
+ return generateRedTeamHTMLReport(manifest as RedTeamManifest);
45
+ case 'stress':
46
+ return generateStressHTMLReport(manifest as StressManifest);
47
+ default:
48
+ return generateHTMLReport(manifest as RunManifest);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Generate JSON report based on manifest type
54
+ */
55
+ function generateJSON(manifest: AnyManifest): string {
56
+ const type = getManifestType(manifest);
57
+ switch (type) {
58
+ case 'redteam':
59
+ return generateJSONReport(manifest as RedTeamManifest, { pretty: true });
60
+ case 'stress':
61
+ return generateJSONReport(manifest as StressManifest, { pretty: true });
62
+ default:
63
+ return generateJSONReport(manifest as RunManifest, { pretty: true });
64
+ }
65
+ }
66
+
67
+ export function reportCommand(): Command {
68
+ const cmd = new Command('report');
69
+
70
+ cmd
71
+ .description('Generate a report from a stored run')
72
+ .argument('<run-id>', 'Run ID to generate report for')
73
+ .option('-f, --format <format>', 'Output format (html, json, both)', 'html')
74
+ .option('-o, --output <dir>', 'Output directory', './artemis-output')
75
+ .option('--config <path>', 'Path to config file')
76
+ .action(async (runId: string, options: ReportOptions) => {
77
+ const spinner = ora('Loading run...').start();
78
+
79
+ try {
80
+ const config = await loadConfig(options.config);
81
+ const storage = createStorage({ fileConfig: config });
82
+ const manifest = await storage.load(runId);
83
+ const manifestType = getManifestType(manifest);
84
+ spinner.succeed(`Loaded ${manifestType} run: ${runId}`);
85
+
86
+ // Create output directory
87
+ const outputDir = options.output || './artemis-output';
88
+ await mkdir(outputDir, { recursive: true });
89
+
90
+ const format = options.format || 'html';
91
+ const generatedFiles: string[] = [];
92
+
93
+ if (format === 'html' || format === 'both') {
94
+ spinner.start('Generating HTML report...');
95
+ const html = generateHTML(manifest);
96
+ const htmlPath = join(outputDir, `${runId}.html`);
97
+ await writeFile(htmlPath, html);
98
+ generatedFiles.push(htmlPath);
99
+ spinner.succeed(`Generated HTML report: ${htmlPath}`);
100
+ }
101
+
102
+ if (format === 'json' || format === 'both') {
103
+ spinner.start('Generating JSON report...');
104
+ const json = generateJSON(manifest);
105
+ const jsonPath = join(outputDir, `${runId}.json`);
106
+ await writeFile(jsonPath, json);
107
+ generatedFiles.push(jsonPath);
108
+ spinner.succeed(`Generated JSON report: ${jsonPath}`);
109
+ }
110
+
111
+ console.log();
112
+ console.log(chalk.bold('Report generated successfully!'));
113
+ console.log();
114
+ console.log('Files:');
115
+ for (const file of generatedFiles) {
116
+ console.log(` ${chalk.green('•')} ${file}`);
117
+ }
118
+ } catch (error) {
119
+ spinner.fail('Error');
120
+ console.error(chalk.red('Error:'), (error as Error).message);
121
+ process.exit(1);
122
+ }
123
+ });
124
+
125
+ return cmd;
126
+ }