kiroo 0.9.0 → 0.9.5

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.
package/README.md CHANGED
@@ -141,7 +141,7 @@ Initialize Kiroo in your current project.
141
141
  kiroo init
142
142
  ```
143
143
 
144
- ### `kiroo get/post/put/delete <url>`
144
+ ### `kiroo get/post/put/delete/patch <url>`
145
145
  Execute and record an API interaction.
146
146
  - **Description**: Performs an HTTP request, displays the response, and saves it to history.
147
147
  - **Prerequisites**: Access to the URL (or a `baseUrl` set in the environment).
@@ -154,6 +154,7 @@ Execute and record an API interaction.
154
154
  - **Example**:
155
155
  ```bash
156
156
  kiroo post /api/auth/login -d "email=user@test.com password=123" --save token=data.token
157
+ kiroo patch /api/profile -d "name=UpdatedName"
157
158
  ```
158
159
 
159
160
  ### `kiroo list`
@@ -271,9 +272,15 @@ Snapshot management.
271
272
  - `list`: List all saved snapshots.
272
273
  - `compare <tag1> <tag2>`: Detect structural changes between two states.
273
274
  - `compare <tag1> <tag2> --analyze`: Structural compare + semantic severity in one run.
275
+ - `run <tag>`: Execute all interactions in a snapshot sequentially (supports auth token chaining).
276
+ - **Options for `run`**:
277
+ - `-v, --verbose`: Show response preview for each request.
278
+ - `--fail-fast`: Stop immediately on first failed interaction.
279
+ - `--timeout <ms>`: Request timeout in milliseconds (Default: 30000).
274
280
  - **Example**:
275
281
  ```bash
276
282
  kiroo snapshot compare v1.stable current
283
+ kiroo snapshot run v1.stable --fail-fast
277
284
  ```
278
285
 
279
286
  ### `kiroo analyze <tag1> <tag2>`
@@ -349,6 +356,17 @@ Wipe history.
349
356
  kiroo clear --force
350
357
  ```
351
358
 
359
+ ### `kiroo help [command]`
360
+ Get command-level help directly in CLI.
361
+ - **Description**: Shows global help or detailed help for a specific command.
362
+ - **Arguments**:
363
+ - `command`: (Optional) Command name to inspect.
364
+ - **Example**:
365
+ ```bash
366
+ kiroo help
367
+ kiroo help bench
368
+ ```
369
+
352
370
  ---
353
371
 
354
372
  ## 🤝 Contributing
package/bin/kiroo.js CHANGED
@@ -17,19 +17,24 @@ import { editInteraction } from '../src/edit.js';
17
17
  import { exportInteractions } from '../src/export.js';
18
18
  import { runProxy } from '../src/proxy.js';
19
19
  import { analyzeSnapshots } from '../src/analyze.js';
20
+ import { runSnapshot } from '../src/run.js';
20
21
 
21
22
  const program = new Command();
22
23
 
23
24
  program
24
25
  .name('kiroo')
25
26
  .description('Git for API interactions. Record, replay, snapshot, and diff your APIs.')
26
- .version('0.8.0')
27
+ .version('0.9.5')
28
+ .showSuggestionAfterError()
27
29
  .option('--lang <language>', 'Translate output to specified language (e.g., hi, es, fr)');
28
30
 
29
31
  // Init command
30
32
  program
31
33
  .command('init')
32
34
  .description('Initialize Kiroo in current directory')
35
+ .addHelpText('after', `
36
+ Examples:
37
+ $ kiroo init`)
33
38
  .action(async () => {
34
39
  await initProject();
35
40
  });
@@ -39,17 +44,25 @@ program
39
44
  program
40
45
  .command('check <url>')
41
46
  .description('Execute a request and validate the response against rules')
47
+ .addHelpText('after', `
48
+ Examples:
49
+ $ kiroo check /api/users --status 200 --has id,email
50
+ $ kiroo check http://api.example.com/login --match status=active`)
42
51
  .option('-m, --method <method>', 'HTTP method (GET, POST, etc.)', 'GET')
52
+ // ... (keep options as is)
43
53
  .option('-H, --header <header...>', 'Add custom headers')
44
54
  .option('-d, --data <data>', 'Request body (JSON or shorthand)')
45
55
  .option('--status <code...>', 'Expected HTTP status code')
46
56
  .option('--has <fields...>', 'Comma-separated list of expected fields in JSON response')
47
57
  .option('--match <matches...>', 'Expected field values (e.g., status=active)')
48
58
  .action(async (url, options) => {
59
+ // ...
49
60
  // Execute request
61
+ const opts = program.opts();
50
62
  const response = await executeRequest(options.method || 'GET', url, {
51
63
  header: options.header,
52
64
  data: options.data,
65
+ lang: opts.lang
53
66
  });
54
67
 
55
68
  if (!response) {
@@ -78,13 +91,12 @@ program
78
91
 
79
92
  // Validate
80
93
  const validation = validateResponse(response, rules);
81
- showCheckResult(validation);
94
+ await showCheckResult(validation, opts.lang);
82
95
 
83
96
  if (!validation.passed) {
84
97
  process.exit(1);
85
98
  }
86
99
  });
87
- // sk_live_p7BWJjsYlKmauBOjiEeiLRuu4DokkBWsgYne_E6osTo
88
100
 
89
101
  // HTTP methods as commands
90
102
  ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].forEach(method => {
@@ -96,7 +108,8 @@ program
96
108
  .option('-d, --data <data>', 'Request body (JSON string or key=value pairs)')
97
109
  .option('-s, --save <pairs...>', 'Extract values from response to env (key=path.to.data)')
98
110
  .action(async (url, options) => {
99
- await executeRequest(method, url, options);
111
+ const opts = program.opts();
112
+ await executeRequest(method, url, { ...options, lang: opts.lang });
100
113
  });
101
114
  });
102
115
 
@@ -155,7 +168,8 @@ program
155
168
  .option('-v, --verbose', 'Show detailed output for every request')
156
169
  .option('-d, --data <data>', 'Request body')
157
170
  .action(async (url, options) => {
158
- await runBenchmark(url, options);
171
+ const opts = program.opts();
172
+ await runBenchmark(url, { ...options, lang: opts.lang });
159
173
  });
160
174
 
161
175
  // Clear command
@@ -266,6 +280,20 @@ snapshot
266
280
  }
267
281
  });
268
282
 
283
+ snapshot
284
+ .command('run <tag>')
285
+ .description('Execute all interactions from a snapshot sequentially (auto-chains auth tokens)')
286
+ .option('-v, --verbose', 'Show response body preview for each request')
287
+ .option('--fail-fast', 'Exit immediately on first failure')
288
+ .option('--timeout <ms>', 'Request timeout in milliseconds', '30000')
289
+ .action(async (tag, options) => {
290
+ await runSnapshot(tag, {
291
+ verbose: !!options.verbose,
292
+ failFast: !!options.failFast,
293
+ timeout: parseInt(options.timeout, 10) || 30000,
294
+ });
295
+ });
296
+
269
297
  program
270
298
  .command('analyze <tag1> <tag2>')
271
299
  .description('Semantic blast-radius analysis between two snapshots')
@@ -336,9 +364,33 @@ program
336
364
  // Stats command
337
365
  program
338
366
  .command('stats')
339
- .description('Show usage statistics')
367
+ .alias('stat')
368
+ .description('Show usage statistics and bottlenecks')
369
+ .addHelpText('after', `
370
+ Examples:
371
+ $ kiroo stats
372
+ $ kiroo stats --lang hi`)
340
373
  .action(async () => {
341
- await showStats();
374
+ const opts = program.opts();
375
+ await showStats({ lang: opts.lang });
376
+ });
377
+
378
+ // Help command
379
+ program
380
+ .command('help [command]')
381
+ .description('Display help for a command')
382
+ .action((cmd) => {
383
+ if (cmd) {
384
+ const subCommand = program.commands.find(c => c.name() === cmd || c.aliases().includes(cmd));
385
+ if (subCommand) {
386
+ subCommand.help();
387
+ } else {
388
+ console.error(chalk.red(`\n ✗ Unknown command: ${cmd}`));
389
+ program.help();
390
+ }
391
+ } else {
392
+ program.help();
393
+ }
342
394
  });
343
395
 
344
396
  // Error handling
@@ -347,15 +399,14 @@ program.exitOverride();
347
399
  try {
348
400
  await program.parseAsync(process.argv);
349
401
  } catch (err) {
350
- if (err.code === 'commander.help' || err.message === '(outputHelp)') {
351
- // Help was requested, exit normally
402
+ if (err.code === 'commander.help' || err.code === 'commander.version' || err.message === '(outputHelp)') {
352
403
  process.exit(0);
353
- } else if (err.code === 'commander.unknownCommand') {
354
- console.error(chalk.red(`\n ✗ Unknown command: ${err.message}\n`));
355
- console.log(chalk.gray(' Run'), chalk.white('kiroo --help'), chalk.gray('for usage information.\n'));
404
+ } else if (err.code === 'commander.unknownCommand' || err.code === 'commander.missingArgument') {
405
+ // Commander already printed the error and suggestion, just exit
356
406
  process.exit(1);
357
407
  } else {
358
- console.error(chalk.red('\n ✗ Error:'), err.message, `(${err.code})`, '\n');
408
+ // For other unexpected errors, we print our own formatted message
409
+ console.error(chalk.red('\n ✗ Error:'), err.message, '\n');
359
410
  process.exit(1);
360
411
  }
361
412
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiroo",
3
- "version": "0.9.0",
3
+ "version": "0.9.5",
4
4
  "description": "Git for API interactions. Record, replay, snapshot, and diff your APIs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/analyze.js CHANGED
@@ -500,18 +500,25 @@ export async function analyzeSnapshots(tag1, tag2, options = {}) {
500
500
  if (options.json) {
501
501
  console.log(stableJSONStringify(report));
502
502
  } else {
503
- console.log(chalk.cyan('\n 🧠 Blast Radius Analysis'));
503
+ let header = '🧠 Blast Radius Analysis';
504
+ if (options.lang) header = await translateText(header, options.lang);
505
+ console.log(chalk.cyan(`\n ${header}`));
504
506
  console.log(chalk.gray(` Source: ${tag1}`));
505
507
  console.log(chalk.gray(` Target: ${tag2}\n`));
506
508
 
507
509
  const shownEndpoints = report.endpoints.slice(0, 6);
508
510
  for (const endpoint of shownEndpoints) {
511
+ let severityLabel = endpoint.highestSeverity.toUpperCase();
512
+ if (options.lang) severityLabel = await translateText(severityLabel, options.lang);
509
513
  const severityColor = colorForSeverity(endpoint.highestSeverity);
510
- console.log(` ${severityColor(endpoint.highestSeverity.toUpperCase())} ${chalk.white(endpoint.method)} ${chalk.gray(endpoint.path)}`);
511
- endpoint.issues.slice(0, 4).forEach((issue) => {
514
+ console.log(` ${severityColor(severityLabel)} ${chalk.white(endpoint.method)} ${chalk.gray(endpoint.path)}`);
515
+
516
+ for (const issue of endpoint.issues.slice(0, 4)) {
512
517
  const issueColor = colorForSeverity(issue.severity);
513
- console.log(` - ${issueColor(issue.severity)} ${issue.message}`);
514
- });
518
+ let issueMsg = issue.message;
519
+ if (options.lang) issueMsg = await translateText(issueMsg, options.lang);
520
+ console.log(` - ${issueColor(issue.severity)} ${issueMsg}`);
521
+ }
515
522
  if (endpoint.issues.length > 4) {
516
523
  console.log(chalk.gray(` - ... +${endpoint.issues.length - 4} more issues`));
517
524
  }
@@ -531,10 +538,21 @@ export async function analyzeSnapshots(tag1, tag2, options = {}) {
531
538
  modelPriority,
532
539
  maxTokens
533
540
  });
534
- console.log(chalk.magenta(`\n 🤖 AI Summary (${ai.model})`));
535
- toConciseBullets(ai.summary, report).forEach((bullet) => {
536
- console.log(` ${bullet}`);
537
- });
541
+ let aiSectionTitle = `🤖 AI Summary (${ai.model})`;
542
+ if (options.lang) aiSectionTitle = await translateText(aiSectionTitle, options.lang);
543
+ console.log(chalk.magenta(`\n ${aiSectionTitle}`));
544
+
545
+ const bullets = toConciseBullets(ai.summary, report);
546
+ for (const bullet of bullets) {
547
+ let finalBullet = bullet;
548
+ if (options.lang) {
549
+ // Translate the text part of the bullet
550
+ const bulletText = bullet.replace(/^- /, '');
551
+ const translatedBullet = await translateText(bulletText, options.lang);
552
+ finalBullet = `- ${translatedBullet}`;
553
+ }
554
+ console.log(` ${finalBullet}`);
555
+ }
538
556
  }
539
557
 
540
558
  if (failOnSeverity && shouldFail(report, failOnSeverity)) {
package/src/bench.js CHANGED
@@ -4,6 +4,7 @@ import axios from 'axios';
4
4
  import ora from 'ora';
5
5
  import { loadEnv } from './storage.js';
6
6
  import { applyEnvReplacements } from './executor.js';
7
+ import { translateText } from './lingo.js';
7
8
 
8
9
  export async function runBenchmark(url, options) {
9
10
  const envData = loadEnv();
@@ -177,7 +178,7 @@ export async function runBenchmark(url, options) {
177
178
  }
178
179
  };
179
180
 
180
- const finalizeBenchmark = () => {
181
+ const finalizeBenchmark = async () => {
181
182
  const totalTime = Date.now() - startTime;
182
183
  if (!isVerbose) spinner.stop();
183
184
 
@@ -191,7 +192,9 @@ export async function runBenchmark(url, options) {
191
192
  avg = Math.round(results.times.reduce((a, b) => a + b, 0) / results.times.length);
192
193
  }
193
194
 
194
- console.log('\n ' + chalk.blue.bold('🚀 Benchmark Results'));
195
+ let resultTitle = '🚀 Benchmark Results';
196
+ if (options.lang) resultTitle = await translateText(resultTitle, options.lang);
197
+ console.log('\n ' + chalk.blue.bold(resultTitle));
195
198
  console.log(' ' + chalk.gray(`${method} ${targetUrl}\n`));
196
199
 
197
200
  const statsTable = new Table({
@@ -212,9 +215,13 @@ export async function runBenchmark(url, options) {
212
215
  console.log(statsTable.toString());
213
216
 
214
217
  if (results.failures > 0) {
215
- console.log(chalk.red(`\n ⚠️ ${results.failures} requests failed (HTTP 4xx/5xx or Network Error).\n`));
218
+ let errorMsg = `⚠️ ${results.failures} requests failed (HTTP 4xx/5xx or Network Error).`;
219
+ if (options.lang) errorMsg = await translateText(errorMsg, options.lang);
220
+ console.log(chalk.red(`\n ${errorMsg}\n`));
216
221
  } else {
217
- console.log(chalk.green(`\n ✅ All requests completed successfully.\n`));
222
+ let successMsg = '✅ All requests completed successfully.';
223
+ if (options.lang) successMsg = await translateText(successMsg, options.lang);
224
+ console.log(chalk.green(`\n ${successMsg}\n`));
218
225
  }
219
226
 
220
227
  resolve();
package/src/checker.js CHANGED
@@ -76,24 +76,41 @@ function getDeep(obj, path) {
76
76
  return keys.reduce((acc, key) => acc && acc[key], obj);
77
77
  }
78
78
 
79
- export function showCheckResult(validation) {
80
- console.log(chalk.cyan('\n 🧪 Test Results:'));
79
+ import { translateText } from './lingo.js';
80
+
81
+ export async function showCheckResult(validation, lang) {
82
+ let title = '🧪 Test Results:';
83
+ if (lang) title = await translateText(title, lang);
84
+ console.log(chalk.cyan(`\n ${title}`));
81
85
 
82
- validation.results.forEach(res => {
86
+ for (const res of validation.results) {
83
87
  const icon = res.passed ? chalk.green('✓') : chalk.red('✗');
84
88
  const color = res.passed ? chalk.white : chalk.red;
85
89
 
86
- console.log(` ${icon} ${res.label}`);
90
+ let label = res.label;
91
+ if (lang) label = await translateText(label, lang);
92
+ console.log(` ${icon} ${label}`);
93
+
87
94
  if (!res.passed) {
88
- console.log(chalk.gray(` Expected: ${res.expected}`));
89
- console.log(chalk.gray(` Actual: ${res.actual}`));
95
+ let expectedLabel = 'Expected:';
96
+ let actualLabel = 'Actual:';
97
+ if (lang) {
98
+ expectedLabel = await translateText(expectedLabel, lang);
99
+ actualLabel = await translateText(actualLabel, lang);
100
+ }
101
+ console.log(chalk.gray(` ${expectedLabel} ${res.expected}`));
102
+ console.log(chalk.gray(` ${actualLabel} ${res.actual}`));
90
103
  }
91
- });
104
+ }
92
105
 
93
106
  if (validation.passed) {
94
- console.log(chalk.green.bold('\n ✨ ALL TESTS PASSED! \n'));
107
+ let msg = '✨ ALL TESTS PASSED!';
108
+ if (lang) msg = await translateText(msg, lang);
109
+ console.log(chalk.green.bold(`\n ${msg} \n`));
95
110
  } else {
96
- console.log(chalk.red.bold('\n ❌ SOME TESTS FAILED \n'));
111
+ let msg = '❌ SOME TESTS FAILED';
112
+ if (lang) msg = await translateText(msg, lang);
113
+ console.log(chalk.red.bold(`\n ${msg} \n`));
97
114
  process.exit(1);
98
115
  }
99
116
  }
package/src/executor.js CHANGED
@@ -177,7 +177,7 @@ export async function executeRequest(method, url, options = {}) {
177
177
  spinner.succeed(chalk.green(`${response.status} ${response.statusText}`) + chalk.gray(` (${duration}ms)`));
178
178
 
179
179
  // Format and display response
180
- console.log(formatResponse(response));
180
+ console.log(await formatResponse(response, options.lang));
181
181
 
182
182
  // Handle --save option
183
183
  if (options.save) {
package/src/export.js CHANGED
@@ -96,10 +96,12 @@ function getPathAndOrigin(rawUrl) {
96
96
  }
97
97
 
98
98
  function inferSchema(value) {
99
- if (value === null) return { nullable: true };
99
+ if (value === null) return { type: 'object', nullable: true };
100
100
  if (Array.isArray(value)) {
101
101
  if (value.length === 0) return { type: 'array', items: {} };
102
- const mergedItem = value.map((item) => inferSchema(item)).reduce(mergeSchemas, {});
102
+ // Only infer from first 5 items to avoid noise from duplicates
103
+ const sample = value.slice(0, 5);
104
+ const mergedItem = sample.map((item) => inferSchema(item)).reduce(mergeSchemas, {});
103
105
  return { type: 'array', items: mergedItem };
104
106
  }
105
107
  if (value && typeof value === 'object') {
@@ -253,6 +255,37 @@ function hasHeader(headers, name) {
253
255
  return Object.keys(headers || {}).some((k) => k.toLowerCase() === name.toLowerCase());
254
256
  }
255
257
 
258
+ const SENSITIVE_PATTERNS = [
259
+ { regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, replace: '<REDACTED_EMAIL>' },
260
+ { regex: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, replace: '<REDACTED_JWT>' },
261
+ { regex: /\b(sk_live_|pk_live_|sk_test_|pk_test_)[A-Za-z0-9_-]{10,}/g, replace: '<REDACTED_KEY>' },
262
+ { regex: /\b(gsk_|ghp_|gho_|glpat-)[A-Za-z0-9_-]{10,}/g, replace: '<REDACTED_KEY>' },
263
+ ];
264
+
265
+ function redactValue(val) {
266
+ if (typeof val !== 'string') return val;
267
+ let result = val;
268
+ for (const { regex, replace } of SENSITIVE_PATTERNS) {
269
+ result = result.replace(regex, replace);
270
+ }
271
+ return result;
272
+ }
273
+
274
+ function truncateAndRedact(value, maxArrayItems = 3) {
275
+ if (value === null || value === undefined) return value;
276
+ if (typeof value === 'string') return redactValue(value);
277
+ if (typeof value !== 'object') return value;
278
+ if (Array.isArray(value)) {
279
+ const sliced = value.slice(0, maxArrayItems);
280
+ return sliced.map((item) => truncateAndRedact(item, maxArrayItems));
281
+ }
282
+ const result = {};
283
+ for (const [k, v] of Object.entries(value)) {
284
+ result[k] = truncateAndRedact(v, maxArrayItems);
285
+ }
286
+ return result;
287
+ }
288
+
256
289
  function buildOpenApiSpec(interactions, options = {}) {
257
290
  const title = options.title || 'Kiroo Traffic API';
258
291
  const version = options.apiVersion || '1.0.0';
@@ -368,7 +401,7 @@ function buildOpenApiSpec(interactions, options = {}) {
368
401
  content: {
369
402
  [mimeType]: {
370
403
  schema: mergedBodySchema,
371
- example: op.requestBodies[0]
404
+ example: truncateAndRedact(op.requestBodies[0])
372
405
  }
373
406
  }
374
407
  };
@@ -377,17 +410,41 @@ function buildOpenApiSpec(interactions, options = {}) {
377
410
  const sortedStatuses = Array.from(op.responses.keys()).sort((a, b) => a.localeCompare(b));
378
411
  sortedStatuses.forEach((status) => {
379
412
  const res = op.responses.get(status);
413
+ const statusNum = parseInt(status, 10);
414
+
415
+ // Meaningful description based on status code
416
+ const description = statusNum === 200 ? 'Successful response'
417
+ : statusNum === 201 ? 'Resource created'
418
+ : statusNum === 204 ? 'No content'
419
+ : statusNum === 304 ? 'Not modified'
420
+ : statusNum === 400 ? 'Bad request'
421
+ : statusNum === 401 ? 'Unauthorized'
422
+ : statusNum === 403 ? 'Forbidden'
423
+ : statusNum === 404 ? 'Not found'
424
+ : statusNum === 409 ? 'Conflict'
425
+ : statusNum === 500 ? 'Internal server error'
426
+ : `Response ${status}`;
427
+
428
+ // 204 and 304 typically have no body
429
+ if (statusNum === 204 || statusNum === 304) {
430
+ operation.responses[status] = { description };
431
+ return;
432
+ }
433
+
380
434
  const mimeType = Array.from(res.mimeTypes)[0] || 'application/json';
381
435
  const schema = res.bodies.length > 0
382
436
  ? res.bodies.map((b) => inferSchema(b)).reduce(mergeSchemas, {})
383
437
  : {};
384
438
 
439
+ // Recursively truncate arrays and redact sensitive data in examples
440
+ let example = res.example !== undefined ? truncateAndRedact(res.example) : undefined;
441
+
385
442
  operation.responses[status] = {
386
- description: 'Observed response',
443
+ description,
387
444
  content: {
388
445
  [mimeType]: {
389
446
  schema,
390
- ...(res.example !== undefined ? { example: res.example } : {})
447
+ ...(example !== undefined ? { example } : {})
391
448
  }
392
449
  }
393
450
  };
package/src/formatter.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
 
3
- export function formatResponse(response) {
3
+ import { translateText, translateResponseData } from './lingo.js';
4
+
5
+ export async function formatResponse(response, lang) {
4
6
  const lines = [];
5
7
 
6
8
  // Status
@@ -21,7 +23,10 @@ export function formatResponse(response) {
21
23
  .slice(0, 3);
22
24
 
23
25
  if (headers.length > 0) {
24
- lines.push(chalk.gray(' Headers:'));
26
+ let headersLabel = ' Headers:';
27
+ if (lang) headersLabel = await translateText(headersLabel, lang);
28
+ lines.push(chalk.gray(headersLabel));
29
+
25
30
  headers.forEach(([key, value]) => {
26
31
  const displayValue = typeof value === 'string' && value.length > 50
27
32
  ? value.substring(0, 50) + '...'
@@ -33,17 +38,24 @@ export function formatResponse(response) {
33
38
 
34
39
  // Body
35
40
  if (response.data) {
36
- lines.push(chalk.gray(' Response:'));
41
+ let responseLabel = ' Response:';
42
+ if (lang) responseLabel = await translateText(responseLabel, lang);
43
+ lines.push(chalk.gray(responseLabel));
44
+
45
+ let displayData = response.data;
46
+ if (lang) {
47
+ displayData = await translateResponseData(response.data, lang);
48
+ }
37
49
 
38
- if (typeof response.data === 'object') {
50
+ if (typeof displayData === 'object') {
39
51
  // Pretty print JSON
40
- const json = JSON.stringify(response.data, null, 2);
52
+ const json = JSON.stringify(displayData, null, 2);
41
53
  json.split('\n').forEach(line => {
42
54
  lines.push(chalk.cyan(` ${line}`));
43
55
  });
44
56
  } else {
45
57
  // Plain text
46
- const text = String(response.data);
58
+ const text = String(displayData);
47
59
  const preview = text.length > 500 ? text.substring(0, 500) + '...' : text;
48
60
  lines.push(chalk.white(` ${preview}`));
49
61
  }
package/src/lingo.js CHANGED
@@ -15,6 +15,7 @@ function getLingoEngine() {
15
15
  }
16
16
 
17
17
  export async function translateText(text, targetLang) {
18
+ if (!text || typeof text !== 'string' || text.trim() === '') return text;
18
19
  const engine = getLingoEngine();
19
20
  if (!engine) return text;
20
21
 
@@ -26,7 +27,29 @@ export async function translateText(text, targetLang) {
26
27
  });
27
28
  return result;
28
29
  } catch (error) {
29
- console.log(chalk.red(`\n ⚠️ Translation failed: ${error.message}`));
30
+ // console.log(chalk.red(`\n ⚠️ Translation failed: ${error.message}`));
30
31
  return text;
31
32
  }
32
33
  }
34
+
35
+ /**
36
+ * Recursively translates strings within an object or array
37
+ */
38
+ export async function translateResponseData(data, targetLang) {
39
+ if (!data) return data;
40
+ if (typeof data === 'string') {
41
+ return await translateText(data, targetLang);
42
+ }
43
+ if (Array.isArray(data)) {
44
+ return await Promise.all(data.map(item => translateResponseData(item, targetLang)));
45
+ }
46
+ if (typeof data === 'object') {
47
+ const translated = {};
48
+ for (const [key, value] of Object.entries(data)) {
49
+ // Don't translate keys, just values
50
+ translated[key] = await translateResponseData(value, targetLang);
51
+ }
52
+ return translated;
53
+ }
54
+ return data;
55
+ }
package/src/proxy.js CHANGED
@@ -91,8 +91,28 @@ export async function runProxy(targetHost, options = {}) {
91
91
  });
92
92
  });
93
93
 
94
+ // Add CORS headers to proxied responses
95
+ proxy.on('proxyRes', function (proxyRes, req, res) {
96
+ proxyRes.headers['access-control-allow-origin'] = req.headers.origin || '*';
97
+ proxyRes.headers['access-control-allow-credentials'] = 'true';
98
+ });
99
+
94
100
  const server = http.createServer((req, res) => {
95
101
  req.kirooStartTime = Date.now();
102
+
103
+ // Handle CORS preflight
104
+ if (req.method === 'OPTIONS') {
105
+ res.writeHead(204, {
106
+ 'Access-Control-Allow-Origin': req.headers.origin || '*',
107
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
108
+ 'Access-Control-Allow-Headers': req.headers['access-control-request-headers'] || '*',
109
+ 'Access-Control-Allow-Credentials': 'true',
110
+ 'Access-Control-Max-Age': '86400',
111
+ });
112
+ res.end();
113
+ return;
114
+ }
115
+
96
116
  let bodyChunks = [];
97
117
 
98
118
  req.on('data', (chunk) => {
package/src/run.js ADDED
@@ -0,0 +1,246 @@
1
+ import axios from 'axios';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { loadSnapshotData, loadEnv } from './storage.js';
5
+
6
+ const REDACTED_RE = /<REDACTED[^>]*>/i;
7
+
8
+ function isRedacted(val) {
9
+ return typeof val === 'string' && REDACTED_RE.test(val.trim());
10
+ }
11
+
12
+ /**
13
+ * Sort interactions chronologically (oldest first) so login
14
+ * runs before authenticated requests.
15
+ */
16
+ function sortChronologically(interactions) {
17
+ return [...interactions].sort((a, b) => {
18
+ // IDs are timestamps like "2026-03-15T08-10-57-031Z"
19
+ const idA = a.id || '';
20
+ const idB = b.id || '';
21
+ return idA.localeCompare(idB);
22
+ });
23
+ }
24
+
25
+ /**
26
+ * Deduplicate redundant interactions (e.g. 10 identical GET /api/projects 304s).
27
+ * Keeps unique method+path combinations, but allows duplicates for different
28
+ * status codes or non-cacheable methods (POST/PUT/DELETE).
29
+ */
30
+ function deduplicateInteractions(interactions) {
31
+ const seen = new Map();
32
+ return interactions.filter((int) => {
33
+ const method = String(int.method || int.request?.method || 'GET').toUpperCase();
34
+ // Always keep non-GET (side-effectful) methods
35
+ if (method !== 'GET') return true;
36
+
37
+ const url = int.url || int.request?.url || '/';
38
+ const status = int.response?.status || 0;
39
+ const key = `${method}|${url}|${status}`;
40
+ if (seen.has(key)) return false;
41
+ seen.set(key, true);
42
+ return true;
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Run all interactions from a snapshot sequentially.
48
+ * - Sorted oldest-first so login precedes authenticated requests
49
+ * - Deduplicates redundant 304 GET requests
50
+ * - Auto-chains tokens from auth responses
51
+ * - Resolves <REDACTED> headers/body from env vars
52
+ */
53
+ export async function runSnapshot(tag, options = {}) {
54
+ let snapshot;
55
+ try {
56
+ snapshot = loadSnapshotData(tag);
57
+ } catch (err) {
58
+ console.error(chalk.red(`\n ✗ Snapshot not found: ${tag}`));
59
+ console.log(chalk.gray(` Run 'kiroo snapshot list' to see available snapshots.\n`));
60
+ process.exit(1);
61
+ }
62
+
63
+ const raw = snapshot.interactions || [];
64
+ if (raw.length === 0) {
65
+ console.log(chalk.yellow(`\n ⚠️ Snapshot "${tag}" has no interactions.\n`));
66
+ return;
67
+ }
68
+
69
+ const env = loadEnv();
70
+ const envVars = env.environments[env.current] || {};
71
+ const baseUrl = envVars.baseUrl || '';
72
+
73
+ // Sort chronologically, then deduplicate
74
+ const sorted = sortChronologically(raw);
75
+ const interactions = deduplicateInteractions(sorted);
76
+
77
+ // Captured variables during run (auto-chained)
78
+ const captured = {};
79
+
80
+ console.log(chalk.cyan(`\n ▶ Running snapshot: ${chalk.white(tag)}`));
81
+ console.log(chalk.gray(` ${interactions.length} interactions (${raw.length - interactions.length} duplicates skipped)\n`));
82
+
83
+ let passed = 0;
84
+ let failed = 0;
85
+
86
+ for (let i = 0; i < interactions.length; i++) {
87
+ const int = interactions[i];
88
+ const method = String(int.method || int.request?.method || 'GET').toUpperCase();
89
+ let url = int.url || int.request?.url || '/';
90
+ const headers = { ...(int.request?.headers || {}) };
91
+ let body = int.request?.body ? JSON.parse(JSON.stringify(int.request.body)) : undefined;
92
+
93
+ // Remove internal/hop-by-hop headers
94
+ for (const h of ['host', 'content-length', 'connection', 'accept-encoding', 'if-none-match',
95
+ 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'sec-fetch-dest', 'sec-fetch-mode',
96
+ 'sec-fetch-site', 'dnt', 'origin', 'referer', 'user-agent']) {
97
+ delete headers[h];
98
+ }
99
+
100
+ // Resolve URL — if relative, prepend baseUrl
101
+ if (url.startsWith('/') && baseUrl) {
102
+ const normalized = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
103
+ url = normalized + url;
104
+ }
105
+
106
+ // Fix redacted auth headers
107
+ for (const [key, val] of Object.entries(headers)) {
108
+ if (isRedacted(String(val))) {
109
+ const k = key.toLowerCase();
110
+ if (k === 'authorization') {
111
+ const token = captured.token || envVars.token;
112
+ if (token) headers[key] = token.startsWith('Bearer') ? token : `Bearer ${token}`;
113
+ else delete headers[key];
114
+ } else if (k === 'x-api-key' || k === 'api-key') {
115
+ const apiKey = captured.apiKey || envVars.sk || envVars.apiKey || envVars['x-api-key'];
116
+ if (apiKey) headers[key] = apiKey;
117
+ else delete headers[key];
118
+ } else {
119
+ delete headers[key];
120
+ }
121
+ }
122
+ }
123
+
124
+ // Fix redacted body fields (e.g. password)
125
+ if (body && typeof body === 'object') {
126
+ for (const [key, val] of Object.entries(body)) {
127
+ if (isRedacted(String(val))) {
128
+ // Try to find from env vars (password, secret, etc.)
129
+ const envVal = envVars[key] || envVars[key.toLowerCase()];
130
+ if (envVal) {
131
+ body[key] = envVal;
132
+ }
133
+ // If no env val found, leave <REDACTED> — will fail but at least user knows
134
+ }
135
+ }
136
+ }
137
+
138
+ // Replace {{var}} placeholders
139
+ const allVars = { ...envVars, ...captured };
140
+ url = replaceVars(url, allVars);
141
+ if (typeof body === 'string') body = replaceVars(body, allVars);
142
+ if (body && typeof body === 'object') body = replaceVarsDeep(body, allVars);
143
+ for (const [key, val] of Object.entries(headers)) {
144
+ if (typeof val === 'string') headers[key] = replaceVars(val, allVars);
145
+ }
146
+
147
+ // Execute
148
+ const shortUrl = url.replace(/^https?:\/\/[^/]+/, '');
149
+ const label = `[${i + 1}/${interactions.length}] ${method} ${shortUrl}`;
150
+ const spinner = ora({ text: chalk.gray(label), spinner: 'dots' }).start();
151
+ const start = Date.now();
152
+
153
+ try {
154
+ const res = await axios({
155
+ method: method.toLowerCase(),
156
+ url,
157
+ headers,
158
+ data: body,
159
+ timeout: options.timeout || 30000,
160
+ validateStatus: () => true,
161
+ });
162
+
163
+ const duration = Date.now() - start;
164
+ const status = res.status;
165
+ const statusColor = status >= 400 ? chalk.red : chalk.green;
166
+
167
+ spinner.stopAndPersist({
168
+ symbol: status >= 400 ? chalk.red('✗') : chalk.green('✓'),
169
+ text: `${statusColor(`${status}`)} ${chalk.gray(`${method} ${shortUrl}`)} ${chalk.dim(`${duration}ms`)}`,
170
+ });
171
+
172
+ // Auto-capture tokens from auth-like responses
173
+ if (res.data && typeof res.data === 'object') {
174
+ const data = res.data;
175
+ for (const field of ['token', 'accessToken', 'access_token', 'jwt', 'authToken', 'auth_token']) {
176
+ if (data[field] && typeof data[field] === 'string') {
177
+ captured.token = data[field];
178
+ if (options.verbose) console.log(chalk.dim(` 🔑 Captured ${field}`));
179
+ }
180
+ if (data.data && typeof data.data === 'object' && data.data[field]) {
181
+ captured.token = data.data[field];
182
+ if (options.verbose) console.log(chalk.dim(` 🔑 Captured data.${field}`));
183
+ }
184
+ }
185
+ for (const field of ['apiKey', 'api_key', 'key', 'publishableKey']) {
186
+ if (data[field] && typeof data[field] === 'string') {
187
+ captured.apiKey = data[field];
188
+ }
189
+ }
190
+ }
191
+
192
+ if (options.verbose && res.data) {
193
+ const bodyStr = typeof res.data === 'object' ? JSON.stringify(res.data, null, 2) : String(res.data);
194
+ const lines = bodyStr.split('\n');
195
+ const preview = lines.length > 6 ? lines.slice(0, 6).join('\n') + '\n ...' : lines.join('\n');
196
+ console.log(chalk.gray(` ↳ ${preview.split('\n').join('\n ')}`));
197
+ }
198
+
199
+ if (status < 400) passed++;
200
+ else failed++;
201
+
202
+ } catch (err) {
203
+ const duration = Date.now() - start;
204
+ spinner.stopAndPersist({
205
+ symbol: chalk.red('✗'),
206
+ text: `${chalk.red('ERR')} ${chalk.gray(`${method} ${shortUrl}`)} ${chalk.dim(`${duration}ms`)} ${chalk.red(err.code || err.message)}`,
207
+ });
208
+ failed++;
209
+ }
210
+ }
211
+
212
+ // Summary
213
+ console.log(chalk.cyan(`\n ── Run Complete ──`));
214
+ console.log(chalk.white(` Snapshot: ${tag}`));
215
+ console.log(chalk.green(` Passed: ${passed}`));
216
+ if (failed > 0) console.log(chalk.red(` Failed: ${failed}`));
217
+ console.log(chalk.gray(` Total: ${interactions.length}\n`));
218
+
219
+ if (captured.token) {
220
+ console.log(chalk.dim(` 🔑 Auto-captured token from auth response`));
221
+ console.log('');
222
+ }
223
+
224
+ if (failed > 0 && options.failFast) {
225
+ process.exit(1);
226
+ }
227
+ }
228
+
229
+ function replaceVars(str, vars) {
230
+ return str.replace(/\{\{(.+?)\}\}/g, (match, key) => {
231
+ return vars[key] !== undefined ? vars[key] : match;
232
+ });
233
+ }
234
+
235
+ function replaceVarsDeep(obj, vars) {
236
+ if (typeof obj === 'string') return replaceVars(obj, vars);
237
+ if (Array.isArray(obj)) return obj.map(item => replaceVarsDeep(item, vars));
238
+ if (obj && typeof obj === 'object') {
239
+ const result = {};
240
+ for (const [k, v] of Object.entries(obj)) {
241
+ result[k] = replaceVarsDeep(v, vars);
242
+ }
243
+ return result;
244
+ }
245
+ return obj;
246
+ }
package/src/stats.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import chalk from 'chalk';
2
2
  import Table from 'cli-table3';
3
3
  import { getAllInteractions } from './storage.js';
4
+ import { translateText } from './lingo.js';
4
5
 
5
- export function showStats() {
6
+ export async function showStats(options = {}) {
7
+ const lang = options.lang;
6
8
  const interactions = getAllInteractions();
7
9
 
8
10
  if (interactions.length === 0) {
@@ -11,7 +13,9 @@ export function showStats() {
11
13
  return;
12
14
  }
13
15
 
14
- console.log(chalk.cyan.bold('\n 📊 Kiroo Analytics Dashboard\n'));
16
+ let title = '📊 Kiroo Analytics Dashboard';
17
+ if (lang) title = await translateText(title, lang);
18
+ console.log(chalk.cyan.bold(`\n ${title}\n`));
15
19
 
16
20
  // 1. General Metrics
17
21
  const total = interactions.length;
@@ -26,7 +30,9 @@ export function showStats() {
26
30
  { [chalk.white('Avg. Duration')]: chalk.white(avgDuration + 'ms') }
27
31
  );
28
32
 
29
- console.log(chalk.white.bold(' ● General Performance'));
33
+ let generalHeader = '● General Performance';
34
+ if (lang) generalHeader = await translateText(generalHeader, lang);
35
+ console.log(chalk.white.bold(` ${generalHeader}`));
30
36
  console.log(generalTable.toString());
31
37
 
32
38
  // 2. Method Distribution
@@ -45,7 +51,9 @@ export function showStats() {
45
51
  methodTable.push([chalk.white(method), chalk.white(count), chalk.gray(percentage)]);
46
52
  });
47
53
 
48
- console.log(chalk.white.bold('\n ● Request Distribution'));
54
+ let distHeader = '● Request Distribution';
55
+ if (lang) distHeader = await translateText(distHeader, lang);
56
+ console.log(chalk.white.bold(`\n ${distHeader}`));
49
57
  console.log(methodTable.toString());
50
58
 
51
59
  // 3. Slowest Endpoints
@@ -67,7 +75,9 @@ export function showStats() {
67
75
  ]);
68
76
  });
69
77
 
70
- console.log(chalk.white.bold('\n ● Top 5 Slowest Endpoints (Bottlenecks)'));
78
+ let slowHeader = '● Top 5 Slowest Endpoints (Bottlenecks)';
79
+ if (lang) slowHeader = await translateText(slowHeader, lang);
80
+ console.log(chalk.white.bold(`\n ${slowHeader}`));
71
81
  console.log(slowTable.toString());
72
82
  console.log('');
73
83
  }