@artemiskit/cli 0.1.7 → 0.2.0

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.
@@ -7,7 +7,14 @@ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
8
  import chalk from 'chalk';
9
9
  import { Command } from 'commander';
10
- import { createSpinner, icons } from '../ui/index.js';
10
+ import {
11
+ type InitWizardResult,
12
+ createSpinner,
13
+ icons,
14
+ isInteractive,
15
+ runInitWizard,
16
+ } from '../ui/index.js';
17
+ import { checkForUpdateAndNotify, getCurrentVersion } from '../utils/update-checker.js';
11
18
 
12
19
  const DEFAULT_CONFIG = `# ArtemisKit Configuration
13
20
  project: my-project
@@ -85,19 +92,116 @@ const ENV_KEYS = [
85
92
  'ANTHROPIC_API_KEY=',
86
93
  ];
87
94
 
95
+ /**
96
+ * Generate config content from wizard results
97
+ */
98
+ function generateConfigFromWizard(wizard: InitWizardResult): string {
99
+ const providerConfigs: Record<string, string> = {
100
+ openai: ` openai:
101
+ apiKey: \${OPENAI_API_KEY}
102
+ defaultModel: ${wizard.model}`,
103
+ 'azure-openai': ` azure-openai:
104
+ apiKey: \${AZURE_OPENAI_API_KEY}
105
+ resourceName: \${AZURE_OPENAI_RESOURCE}
106
+ deploymentName: \${AZURE_OPENAI_DEPLOYMENT}
107
+ apiVersion: "2024-02-15-preview"`,
108
+ anthropic: ` anthropic:
109
+ apiKey: \${ANTHROPIC_API_KEY}
110
+ defaultModel: ${wizard.model}`,
111
+ google: ` google:
112
+ apiKey: \${GOOGLE_AI_API_KEY}
113
+ defaultModel: ${wizard.model}`,
114
+ mistral: ` mistral:
115
+ apiKey: \${MISTRAL_API_KEY}
116
+ defaultModel: ${wizard.model}`,
117
+ ollama: ` ollama:
118
+ baseUrl: http://localhost:11434
119
+ defaultModel: ${wizard.model}`,
120
+ };
121
+
122
+ const storageConfig =
123
+ wizard.storageType === 'supabase'
124
+ ? `storage:
125
+ type: supabase
126
+ supabaseUrl: \${SUPABASE_URL}
127
+ supabaseKey: \${SUPABASE_ANON_KEY}`
128
+ : `storage:
129
+ type: local
130
+ basePath: ./artemis-runs`;
131
+
132
+ return `# ArtemisKit Configuration
133
+ project: ${wizard.projectName}
134
+
135
+ # Default provider settings
136
+ provider: ${wizard.provider}
137
+ model: ${wizard.model}
138
+
139
+ # Provider configurations
140
+ providers:
141
+ ${providerConfigs[wizard.provider] || providerConfigs.openai}
142
+
143
+ # Storage configuration
144
+ ${storageConfig}
145
+
146
+ # Scenarios directory
147
+ scenariosDir: ./scenarios
148
+
149
+ # Output settings
150
+ output:
151
+ format: json
152
+ dir: ./artemis-output
153
+ `;
154
+ }
155
+
88
156
  function renderWelcomeBanner(): string {
157
+ // Brand color for "KIT" portion: #fb923c (orange)
158
+ const brandColor = chalk.hex('#fb923c');
159
+ const version = getCurrentVersion();
160
+
161
+ // Randomly color each border character white or brand color
162
+ const colorBorderChar = (char: string): string => {
163
+ return Math.random() > 0.5 ? chalk.white(char) : brandColor(char);
164
+ };
165
+
166
+ const colorBorder = (str: string): string => {
167
+ return str.split('').map(colorBorderChar).join('');
168
+ };
169
+
170
+ // All lines are exactly 52 chars inside the borders for perfect alignment
171
+ const topBorder = `╭${'─'.repeat(52)}╮`;
172
+ const bottomBorder = `╰${'─'.repeat(52)}╯`;
173
+ const sideBorderLeft = '│';
174
+ const sideBorderRight = '│';
175
+ const emptyContent = ' '.repeat(52);
176
+
177
+ // Version line: "v0.1.7" centered in brand color
178
+ const versionText = `v${version}`;
179
+ const versionPadding = Math.floor((52 - versionText.length) / 2);
180
+ const versionLine =
181
+ ' '.repeat(versionPadding) +
182
+ brandColor(versionText) +
183
+ ' '.repeat(52 - versionPadding - versionText.length);
184
+
185
+ // Tagline centered
186
+ const tagline = 'Open-source testing toolkit for LLM applications';
187
+ const taglinePadding = Math.floor((52 - tagline.length) / 2);
188
+ const taglineLine =
189
+ ' '.repeat(taglinePadding) +
190
+ chalk.gray(tagline) +
191
+ ' '.repeat(52 - taglinePadding - tagline.length);
192
+
89
193
  const lines = [
90
194
  '',
91
- chalk.cyan(' ╔═══════════════════════════════════════════════════════╗'),
92
- chalk.cyan(' ║ ║'),
93
- chalk.cyan('') +
94
- chalk.bold.white(' 🎯 Welcome to ArtemisKit ') +
95
- chalk.cyan('║'),
96
- chalk.cyan(' ║') +
97
- chalk.gray(' LLM Testing & Evaluation Toolkit ') +
98
- chalk.cyan('║'),
99
- chalk.cyan(' ║ ║'),
100
- chalk.cyan(' ╚═══════════════════════════════════════════════════════╝'),
195
+ ` ${colorBorder(topBorder)}`,
196
+ ` ${colorBorderChar(sideBorderLeft)}${emptyContent}${colorBorderChar(sideBorderRight)}`,
197
+ ` ${colorBorderChar(sideBorderLeft)} ${chalk.bold.white('▄▀█ █▀█ ▀█▀ █▀▀ █▀▄▀█ █ █▀ ')}${brandColor.bold('█▄▀ █ ▀█▀')} ${colorBorderChar(sideBorderRight)}`,
198
+ ` ${colorBorderChar(sideBorderLeft)} ${chalk.bold.white('█▀█ █▀▄ █ ██▄ ▀ █ █ ▄█ ')}${brandColor.bold('█ █ █ █ ')} ${colorBorderChar(sideBorderRight)}`,
199
+ ` ${colorBorderChar(sideBorderLeft)}${emptyContent}${colorBorderChar(sideBorderRight)}`,
200
+ ` ${colorBorderChar(sideBorderLeft)}${versionLine}${colorBorderChar(sideBorderRight)}`,
201
+ ` ${colorBorderChar(sideBorderLeft)}${emptyContent}${colorBorderChar(sideBorderRight)}`,
202
+ ` ${colorBorderChar(sideBorderLeft)}${taglineLine}${colorBorderChar(sideBorderRight)}`,
203
+ ` ${colorBorderChar(sideBorderLeft)}${emptyContent}${colorBorderChar(sideBorderRight)}`,
204
+ ` ${colorBorder(bottomBorder)}`,
101
205
  '',
102
206
  ];
103
207
  return lines.join('\n');
@@ -193,79 +297,119 @@ export function initCommand(): Command {
193
297
  .description('Initialize ArtemisKit in the current directory')
194
298
  .option('-f, --force', 'Overwrite existing configuration')
195
299
  .option('--skip-env', 'Skip adding environment variables to .env')
196
- .action(async (options: { force?: boolean; skipEnv?: boolean }) => {
197
- const spinner = createSpinner();
198
-
199
- try {
200
- const cwd = process.cwd();
201
-
202
- // Show welcome banner
203
- console.log(renderWelcomeBanner());
204
-
205
- // Step 1: Create directories
206
- spinner.start('Creating project structure...');
207
- await mkdir(join(cwd, 'scenarios'), { recursive: true });
208
- await mkdir(join(cwd, 'artemis-runs'), { recursive: true });
209
- await mkdir(join(cwd, 'artemis-output'), { recursive: true });
210
- spinner.succeed('Created project structure');
211
-
212
- // Step 2: Write config file
213
- const configPath = join(cwd, 'artemis.config.yaml');
214
- const configExists = existsSync(configPath);
215
-
216
- if (configExists && !options.force) {
217
- spinner.info('Config file already exists (use --force to overwrite)');
218
- } else {
219
- spinner.start('Writing configuration...');
220
- await writeFile(configPath, DEFAULT_CONFIG);
221
- spinner.succeed(
222
- configExists ? 'Overwrote artemis.config.yaml' : 'Created artemis.config.yaml'
223
- );
224
- }
300
+ .option('-i, --interactive', 'Run interactive setup wizard')
301
+ .option('-y, --yes', 'Use defaults without prompts (non-interactive)')
302
+ .action(
303
+ async (options: {
304
+ force?: boolean;
305
+ skipEnv?: boolean;
306
+ interactive?: boolean;
307
+ yes?: boolean;
308
+ }) => {
309
+ const spinner = createSpinner();
310
+
311
+ try {
312
+ const cwd = process.cwd();
313
+
314
+ // Show welcome banner
315
+ console.log(renderWelcomeBanner());
316
+
317
+ // Determine if we should run interactive wizard
318
+ const shouldRunWizard =
319
+ options.interactive || (isInteractive() && !options.yes && !options.force);
320
+
321
+ let configContent = DEFAULT_CONFIG;
322
+ let createExample = true;
323
+
324
+ // Run interactive wizard if applicable
325
+ if (shouldRunWizard) {
326
+ try {
327
+ const wizardResult = await runInitWizard();
328
+ configContent = generateConfigFromWizard(wizardResult);
329
+ createExample = wizardResult.createExample;
330
+ console.log(''); // Add spacing after wizard
331
+ } catch (wizardError) {
332
+ // If wizard fails (e.g., user cancels), fall back to defaults
333
+ if ((wizardError as Error).message?.includes('closed')) {
334
+ console.log(chalk.yellow('\n Setup cancelled. Using defaults.\n'));
335
+ } else {
336
+ throw wizardError;
337
+ }
338
+ }
339
+ }
225
340
 
226
- // Step 3: Write example scenario
227
- const scenarioPath = join(cwd, 'scenarios', 'example.yaml');
228
- const scenarioExists = existsSync(scenarioPath);
229
-
230
- if (scenarioExists && !options.force) {
231
- spinner.info('Example scenario already exists (use --force to overwrite)');
232
- } else {
233
- spinner.start('Creating example scenario...');
234
- await writeFile(scenarioPath, DEFAULT_SCENARIO);
235
- spinner.succeed(
236
- scenarioExists ? 'Overwrote scenarios/example.yaml' : 'Created scenarios/example.yaml'
237
- );
238
- }
341
+ // Step 1: Create directories
342
+ spinner.start('Creating project structure...');
343
+ await mkdir(join(cwd, 'scenarios'), { recursive: true });
344
+ await mkdir(join(cwd, 'artemis-runs'), { recursive: true });
345
+ await mkdir(join(cwd, 'artemis-output'), { recursive: true });
346
+ spinner.succeed('Created project structure');
347
+
348
+ // Step 2: Write config file
349
+ const configPath = join(cwd, 'artemis.config.yaml');
350
+ const configExists = existsSync(configPath);
239
351
 
240
- // Step 4: Update .env file
241
- if (!options.skipEnv) {
242
- spinner.start('Updating .env file...');
243
- const { added, skipped } = await appendEnvKeys(cwd);
244
-
245
- if (added.length > 0) {
246
- spinner.succeed(`Added ${added.length} environment variable(s) to .env`);
247
- if (skipped.length > 0) {
248
- console.log(
249
- chalk.dim(
250
- ` ${icons.info} Skipped ${skipped.length} existing key(s): ${skipped.join(', ')}`
251
- )
352
+ if (configExists && !options.force) {
353
+ spinner.info('Config file already exists (use --force to overwrite)');
354
+ } else {
355
+ spinner.start('Writing configuration...');
356
+ await writeFile(configPath, configContent);
357
+ spinner.succeed(
358
+ configExists ? 'Overwrote artemis.config.yaml' : 'Created artemis.config.yaml'
359
+ );
360
+ }
361
+
362
+ // Step 3: Write example scenario (if requested)
363
+ if (createExample) {
364
+ const scenarioPath = join(cwd, 'scenarios', 'example.yaml');
365
+ const scenarioExists = existsSync(scenarioPath);
366
+
367
+ if (scenarioExists && !options.force) {
368
+ spinner.info('Example scenario already exists (use --force to overwrite)');
369
+ } else {
370
+ spinner.start('Creating example scenario...');
371
+ await writeFile(scenarioPath, DEFAULT_SCENARIO);
372
+ spinner.succeed(
373
+ scenarioExists
374
+ ? 'Overwrote scenarios/example.yaml'
375
+ : 'Created scenarios/example.yaml'
252
376
  );
253
377
  }
254
- } else if (skipped.length > 0) {
255
- spinner.info('All environment variables already exist in .env');
256
- } else {
257
- spinner.succeed('Created .env with environment variables');
258
378
  }
259
- }
260
379
 
261
- // Show success panel
262
- console.log(renderSuccessPanel());
263
- } catch (error) {
264
- spinner.fail('Error');
265
- console.error(chalk.red(`\n${icons.failed} ${(error as Error).message}`));
266
- process.exit(1);
380
+ // Step 4: Update .env file
381
+ if (!options.skipEnv) {
382
+ spinner.start('Updating .env file...');
383
+ const { added, skipped } = await appendEnvKeys(cwd);
384
+
385
+ if (added.length > 0) {
386
+ spinner.succeed(`Added ${added.length} environment variable(s) to .env`);
387
+ if (skipped.length > 0) {
388
+ console.log(
389
+ chalk.dim(
390
+ ` ${icons.info} Skipped ${skipped.length} existing key(s): ${skipped.join(', ')}`
391
+ )
392
+ );
393
+ }
394
+ } else if (skipped.length > 0) {
395
+ spinner.info('All environment variables already exist in .env');
396
+ } else {
397
+ spinner.succeed('Created .env with environment variables');
398
+ }
399
+ }
400
+
401
+ // Show success panel
402
+ console.log(renderSuccessPanel());
403
+
404
+ // Non-blocking update check (fire and forget)
405
+ checkForUpdateAndNotify();
406
+ } catch (error) {
407
+ spinner.fail('Error');
408
+ console.error(chalk.red(`\n${icons.failed} ${(error as Error).message}`));
409
+ process.exit(1);
410
+ }
267
411
  }
268
- });
412
+ );
269
413
 
270
414
  return cmd;
271
415
  }
@@ -19,14 +19,18 @@ import {
19
19
  parseScenarioFile,
20
20
  } from '@artemiskit/core';
21
21
  import {
22
+ type ConversationTurn,
22
23
  CotInjectionMutation,
24
+ EncodingMutation,
23
25
  InstructionFlipMutation,
26
+ MultiTurnMutation,
24
27
  type Mutation,
25
28
  RedTeamGenerator,
26
29
  RoleSpoofMutation,
27
30
  SeverityMapper,
28
31
  TypoMutation,
29
32
  UnsafeResponseDetector,
33
+ loadCustomAttacks,
30
34
  } from '@artemiskit/redteam';
31
35
  import { generateJSONReport, generateRedTeamHTMLReport } from '@artemiskit/reports';
32
36
  import chalk from 'chalk';
@@ -55,6 +59,7 @@ interface RedteamOptions {
55
59
  model?: string;
56
60
  mutations?: string[];
57
61
  count?: number;
62
+ customAttacks?: string;
58
63
  save?: boolean;
59
64
  output?: string;
60
65
  verbose?: boolean;
@@ -73,9 +78,10 @@ export function redteamCommand(): Command {
73
78
  .option('-m, --model <model>', 'Model to use')
74
79
  .option(
75
80
  '--mutations <mutations...>',
76
- 'Mutations to apply (typo, role-spoof, instruction-flip, cot-injection)'
81
+ 'Mutations to apply (typo, role-spoof, instruction-flip, cot-injection, encoding, multi-turn)'
77
82
  )
78
83
  .option('-c, --count <number>', 'Number of mutated prompts per case', '5')
84
+ .option('--custom-attacks <path>', 'Path to custom attacks YAML file')
79
85
  .option('--save', 'Save results to storage')
80
86
  .option('-o, --output <dir>', 'Output directory for reports')
81
87
  .option('-v, --verbose', 'Verbose output')
@@ -131,7 +137,7 @@ export function redteamCommand(): Command {
131
137
  spinner.succeed(`Connected to ${provider}`);
132
138
 
133
139
  // Set up mutations
134
- const mutations = selectMutations(options.mutations);
140
+ const mutations = selectMutations(options.mutations, options.customAttacks);
135
141
  const generator = new RedTeamGenerator(mutations);
136
142
  const detector = new UnsafeResponseDetector();
137
143
  const count = Number.parseInt(String(options.count)) || 5;
@@ -179,12 +185,47 @@ export function redteamCommand(): Command {
179
185
  for (const testCase of scenario.cases) {
180
186
  console.log(chalk.bold(`Testing case: ${testCase.id}`));
181
187
 
182
- const originalPrompt =
183
- typeof testCase.prompt === 'string'
184
- ? testCase.prompt
185
- : testCase.prompt.map((m) => m.content).join('\n');
188
+ // Handle both string and array prompts (consistent with run command)
189
+ // For array prompts: last user message is the attack target, rest is context
190
+ let attackPrompt: string;
191
+ let conversationPrefix: ConversationTurn[] | undefined;
192
+
193
+ if (typeof testCase.prompt === 'string') {
194
+ // Simple string prompt - use directly
195
+ attackPrompt = testCase.prompt;
196
+ } else {
197
+ // Array prompt - extract last user message as attack, rest as context
198
+ const messages = testCase.prompt;
199
+ const lastUserIndex = messages.map((m) => m.role).lastIndexOf('user');
200
+
201
+ if (lastUserIndex === -1) {
202
+ // No user message found - use concatenated content
203
+ attackPrompt = messages.map((m) => m.content).join('\n');
204
+ } else {
205
+ // Extract attack prompt (last user message)
206
+ attackPrompt = messages[lastUserIndex].content;
207
+
208
+ // Extract conversation prefix (everything before the last user message)
209
+ if (lastUserIndex > 0) {
210
+ conversationPrefix = messages.slice(0, lastUserIndex).map((m) => ({
211
+ role: m.role as 'user' | 'assistant' | 'system',
212
+ content: m.content,
213
+ }));
214
+ }
215
+ }
216
+ }
217
+
218
+ // Clear any previous prefix and set new one if applicable
219
+ for (const mutation of mutations) {
220
+ if (mutation instanceof MultiTurnMutation) {
221
+ mutation.clearConversationPrefix();
222
+ if (conversationPrefix && conversationPrefix.length > 0) {
223
+ mutation.setConversationPrefix(conversationPrefix);
224
+ }
225
+ }
226
+ }
186
227
 
187
- const mutatedPrompts = generator.generate(originalPrompt, count);
228
+ const mutatedPrompts = generator.generate(attackPrompt, count);
188
229
 
189
230
  for (const mutated of mutatedPrompts) {
190
231
  completedTests++;
@@ -474,19 +515,31 @@ export function redteamCommand(): Command {
474
515
  return cmd;
475
516
  }
476
517
 
477
- function selectMutations(names?: string[]): Mutation[] {
518
+ function selectMutations(names?: string[], customAttacksPath?: string): Mutation[] {
478
519
  const allMutations: Record<string, Mutation> = {
479
520
  typo: new TypoMutation(),
480
521
  'role-spoof': new RoleSpoofMutation(),
481
522
  'instruction-flip': new InstructionFlipMutation(),
482
523
  'cot-injection': new CotInjectionMutation(),
524
+ encoding: new EncodingMutation(),
525
+ 'multi-turn': new MultiTurnMutation(),
483
526
  };
484
527
 
528
+ let mutations: Mutation[];
529
+
485
530
  if (!names || names.length === 0) {
486
- return Object.values(allMutations);
531
+ mutations = Object.values(allMutations);
532
+ } else {
533
+ mutations = names.filter((name) => name in allMutations).map((name) => allMutations[name]);
534
+ }
535
+
536
+ // Load custom attacks if path provided
537
+ if (customAttacksPath) {
538
+ const customMutations = loadCustomAttacks(customAttacksPath);
539
+ mutations.push(...customMutations);
487
540
  }
488
541
 
489
- return names.filter((name) => name in allMutations).map((name) => allMutations[name]);
542
+ return mutations;
490
543
  }
491
544
 
492
545
  /**