kiroo 0.8.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/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/init.js CHANGED
@@ -1,48 +1,80 @@
1
- import chalk from 'chalk';
2
- import inquirer from 'inquirer';
3
- import { writeFileSync, existsSync } from 'fs';
4
- import { ensureKirooDir } from './storage.js';
5
-
6
- export async function initProject() {
7
- console.log('');
8
- console.log(chalk.cyan.bold(' 🚀 Welcome to Kiroo'));
9
- console.log(chalk.gray(' Git for API interactions\n'));
10
-
11
- if (existsSync('.kiroo')) {
12
- console.log(chalk.yellow(' ⚠️ Kiroo is already initialized in this directory.\n'));
13
- return;
14
- }
15
-
16
- const answers = await inquirer.prompt([
17
- {
18
- type: 'input',
19
- name: 'projectName',
20
- message: 'Project name:',
21
- default: 'my-api-project',
22
- },
23
- {
24
- type: 'input',
25
- name: 'baseUrl',
26
- message: 'Base URL (optional):',
27
- default: '',
28
- },
29
- ]);
30
-
31
- ensureKirooDir();
32
-
33
- const config = {
34
- projectName: answers.projectName,
35
- baseUrl: answers.baseUrl,
36
- createdAt: new Date().toISOString(),
37
- };
38
-
39
- writeFileSync('.kiroo/config.json', JSON.stringify(config, null, 2));
40
-
41
- console.log('');
42
- console.log(chalk.green(' ✅ Kiroo initialized successfully!'));
43
- console.log('');
44
- console.log(chalk.gray(' Next steps:'));
45
- console.log(chalk.white(' kiroo POST https://api.example.com/login email=test@test.com'));
46
- console.log(chalk.white(' kiroo list'));
47
- console.log(chalk.white(' kiroo snapshot save initial\n'));
48
- }
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { existsSync } from 'fs';
4
+ import { ensureKirooDir, loadEnv, saveEnv } from './storage.js';
5
+ import { saveKirooConfig } from './config.js';
6
+
7
+ export async function initProject() {
8
+ console.log('');
9
+ console.log(chalk.cyan.bold(' 🚀 Welcome to Kiroo'));
10
+ console.log(chalk.gray(' Git for API interactions\n'));
11
+
12
+ if (existsSync('.kiroo')) {
13
+ console.log(chalk.yellow(' ⚠️ Kiroo is already initialized in this directory.\n'));
14
+ return;
15
+ }
16
+
17
+ const answers = await inquirer.prompt([
18
+ {
19
+ type: 'input',
20
+ name: 'projectName',
21
+ message: 'Project name:',
22
+ default: 'my-api-project',
23
+ },
24
+ {
25
+ type: 'input',
26
+ name: 'baseUrl',
27
+ message: 'Base URL (optional):',
28
+ default: '',
29
+ },
30
+ {
31
+ type: 'password',
32
+ name: 'groqApiKey',
33
+ message: 'Groq API Key (optional, hidden):',
34
+ default: '',
35
+ mask: '*',
36
+ },
37
+ {
38
+ type: 'password',
39
+ name: 'lingoApiKey',
40
+ message: 'Lingo.dev API Key (optional, hidden):',
41
+ default: '',
42
+ mask: '*',
43
+ },
44
+ ]);
45
+
46
+ ensureKirooDir();
47
+
48
+ const config = {
49
+ projectName: answers.projectName,
50
+ baseUrl: answers.baseUrl,
51
+ createdAt: new Date().toISOString(),
52
+ };
53
+
54
+ saveKirooConfig(config);
55
+
56
+ const envData = loadEnv();
57
+ const current = envData.current || 'default';
58
+ if (!envData.environments[current]) {
59
+ envData.environments[current] = {};
60
+ }
61
+
62
+ if (answers.baseUrl) {
63
+ envData.environments[current].baseUrl = answers.baseUrl;
64
+ }
65
+ if (answers.groqApiKey) {
66
+ envData.environments[current].GROQ_API_KEY = answers.groqApiKey;
67
+ }
68
+ if (answers.lingoApiKey) {
69
+ envData.environments[current].LINGODOTDEV_API_KEY = answers.lingoApiKey;
70
+ }
71
+ saveEnv(envData);
72
+
73
+ console.log('');
74
+ console.log(chalk.green(' ✅ Kiroo initialized successfully!'));
75
+ console.log('');
76
+ console.log(chalk.gray(' Next steps:'));
77
+ console.log(chalk.white(' kiroo POST https://api.example.com/login email=test@test.com'));
78
+ console.log(chalk.white(' kiroo list'));
79
+ console.log(chalk.white(' kiroo snapshot save initial\n'));
80
+ }
package/src/lingo.js CHANGED
@@ -1,36 +1,55 @@
1
- import { LingoDotDevEngine } from "lingo.dev/sdk";
2
- import chalk from "chalk";
3
- import { loadEnv } from "./storage.js";
4
-
5
- function getLingoEngine() {
6
- const envData = loadEnv();
7
- const currentEnvVars = envData.environments[envData.current] || {};
8
-
9
- // Prioritize process.env, fallback to kiroo environments
10
- const apiKey = currentEnvVars.LINGODOTDEV_API_KEY;
11
-
12
- if (!apiKey) {
13
- console.log(chalk.yellow(`\n ⚠️ LINGODOTDEV_API_KEY not found.`));
14
- console.log(chalk.gray(`run 'kiroo env set LINGODOTDEV_API_KEY <your_key>'\n`));
15
- return null;
16
- }
17
-
18
- return new LingoDotDevEngine({ apiKey });
19
- }
20
-
21
- export async function translateText(text, targetLang) {
22
- const engine = getLingoEngine();
23
- if (!engine) return text;
24
-
25
- try {
26
- const result = await engine.localizeText(text, {
27
- sourceLocale: 'en',
28
- targetLocale: targetLang,
29
- fast: true
30
- });
31
- return result;
32
- } catch (error) {
33
- console.log(chalk.red(`\n ⚠️ Translation failed: ${error.message}`));
34
- return text;
35
- }
36
- }
1
+ import { LingoDotDevEngine } from "lingo.dev/sdk";
2
+ import chalk from "chalk";
3
+ import { getEnvVar } from "./env.js";
4
+
5
+ function getLingoEngine() {
6
+ const apiKey = getEnvVar('LINGODOTDEV_API_KEY') || getEnvVar('LINGO_API_KEY');
7
+
8
+ if (!apiKey) {
9
+ console.log(chalk.yellow(`\n ⚠️ Lingo API key not found in .kiroo/env.json.`));
10
+ console.log(chalk.gray(` Run 'kiroo env set LINGODOTDEV_API_KEY <your_key>' or re-run 'kiroo init'.\n`));
11
+ return null;
12
+ }
13
+
14
+ return new LingoDotDevEngine({ apiKey });
15
+ }
16
+
17
+ export async function translateText(text, targetLang) {
18
+ if (!text || typeof text !== 'string' || text.trim() === '') return text;
19
+ const engine = getLingoEngine();
20
+ if (!engine) return text;
21
+
22
+ try {
23
+ const result = await engine.localizeText(text, {
24
+ sourceLocale: 'en',
25
+ targetLocale: targetLang,
26
+ fast: true
27
+ });
28
+ return result;
29
+ } catch (error) {
30
+ // console.log(chalk.red(`\n ⚠️ Translation failed: ${error.message}`));
31
+ return text;
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 ADDED
@@ -0,0 +1,140 @@
1
+ import http from 'http';
2
+ import httpProxy from 'http-proxy';
3
+ import chalk from 'chalk';
4
+ import { URL } from 'url';
5
+ import stream from 'stream';
6
+ import { saveInteraction } from './storage.js';
7
+
8
+ export async function runProxy(targetHost, options = {}) {
9
+ const port = parseInt(options.port, 10) || 8080;
10
+
11
+ if (isNaN(port) || port <= 0 || port > 65535) {
12
+ console.error(chalk.red('\n ✗ Invalid port provided.'), options.port);
13
+ console.log(chalk.gray(' Port must be a number between 1 and 65535.\n'));
14
+ process.exit(1);
15
+ }
16
+
17
+ // Validate target URL
18
+ try {
19
+ new URL(targetHost);
20
+ } catch (err) {
21
+ console.error(chalk.red('\n ✗ Invalid target URL provided.'), targetHost);
22
+ console.log(chalk.gray(' Example: kiroo proxy --target http://localhost:3000\n'));
23
+ process.exit(1);
24
+ }
25
+
26
+ const proxy = httpProxy.createProxyServer({
27
+ target: targetHost,
28
+ changeOrigin: true,
29
+ secure: false,
30
+ });
31
+
32
+ // Handle Proxy Errors
33
+ proxy.on('error', (err, req, res) => {
34
+ let errorMsg = err.message;
35
+ if (err.code === 'ECONNREFUSED') {
36
+ errorMsg = `Connection Refused. Is your backend server running on ${targetHost}?`;
37
+ } else if (err.code === 'ENOTFOUND') {
38
+ errorMsg = `Target host not found: ${targetHost}`;
39
+ }
40
+
41
+ console.error(chalk.red(`\n ✗ Proxy error for ${req.method} ${req.url}`));
42
+ console.error(chalk.yellow(` Error: ${errorMsg} (${err.code || 'UNKNOWN'})\n`));
43
+
44
+ if (!res.headersSent && res.writeHead) {
45
+ res.writeHead(502, { 'Content-Type': 'application/json' });
46
+ }
47
+ if (res.end) {
48
+ res.end(JSON.stringify({ error: 'Proxy Error', message: errorMsg, code: err.code }));
49
+ }
50
+ });
51
+
52
+ // Intercept Response to save interaction
53
+ proxy.on('proxyRes', (proxyRes, req, res) => {
54
+ let responseBody = '';
55
+ proxyRes.on('data', (chunk) => {
56
+ responseBody += chunk.toString('utf8');
57
+ });
58
+
59
+ proxyRes.on('end', () => {
60
+ const duration = Date.now() - req.kirooStartTime;
61
+ const fullUrl = new URL(req.url, targetHost).toString();
62
+
63
+ let parsedReqBody = req.kirooBodyData;
64
+ if (req.kirooBodyData) {
65
+ try { parsedReqBody = JSON.parse(req.kirooBodyData); } catch (e) { }
66
+ }
67
+
68
+ let parsedResBody = responseBody;
69
+ if (responseBody) {
70
+ try { parsedResBody = JSON.parse(responseBody); } catch (e) { }
71
+ }
72
+
73
+ // Only save if it's not a CORS preflight with no content
74
+ if (req.method !== 'OPTIONS' || proxyRes.statusCode !== 204) {
75
+ saveInteraction({
76
+ method: req.method,
77
+ url: fullUrl,
78
+ headers: req.headers,
79
+ body: parsedReqBody || undefined,
80
+ response: {
81
+ status: proxyRes.statusCode,
82
+ headers: proxyRes.headers,
83
+ body: parsedResBody || undefined
84
+ },
85
+ duration: duration
86
+ });
87
+
88
+ const statusColor = proxyRes.statusCode >= 400 ? chalk.red : chalk.green;
89
+ console.log(` ${statusColor(req.method)} ${chalk.dim(fullUrl)} ${statusColor(proxyRes.statusCode)} - ${duration}ms`);
90
+ }
91
+ });
92
+ });
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
+
100
+ const server = http.createServer((req, res) => {
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
+
116
+ let bodyChunks = [];
117
+
118
+ req.on('data', (chunk) => {
119
+ bodyChunks.push(chunk);
120
+ });
121
+
122
+ req.on('end', () => {
123
+ const bodyBuffer = Buffer.concat(bodyChunks);
124
+ req.kirooBodyData = bodyBuffer.toString('utf8');
125
+
126
+ // We must re-stream the body because the original req stream is exhausted
127
+ const bufferStream = new stream.PassThrough();
128
+ bufferStream.end(bodyBuffer);
129
+
130
+ proxy.web(req, res, { buffer: bufferStream });
131
+ });
132
+ });
133
+
134
+ server.listen(port, () => {
135
+ console.log(chalk.cyan(`\n 📡 Kiroo Proxy Started`));
136
+ console.log(chalk.white(` Listening on : http://localhost:${port}`));
137
+ console.log(chalk.white(` Forwarding to: ${targetHost}`));
138
+ console.log(chalk.gray(`\n (Press Ctrl+C to stop recording)\n`));
139
+ });
140
+ }
package/src/replay.js CHANGED
@@ -11,7 +11,7 @@ export async function listInteractions(options) {
11
11
 
12
12
  // Apply Filters
13
13
  if (options.date) {
14
- interactions = interactions.filter(int => int.id.startsWith(options.date));
14
+ interactions = interactions.filter(int => int.id.startsWith(options.date));
15
15
  }
16
16
  if (options.url) {
17
17
  interactions = interactions.filter(int => int.request.url.toLowerCase().includes(options.url.toLowerCase()));
@@ -42,12 +42,13 @@ export async function listInteractions(options) {
42
42
  ? chalk.red
43
43
  : chalk.yellow;
44
44
 
45
+ const url = int.request.url || 'N/A';
45
46
  table.push([
46
47
  chalk.white(int.id),
47
- chalk.white(int.request.method),
48
- chalk.gray(int.request.url.substring(0, 42) + (int.request.url.length > 42 ? '...' : '')),
48
+ chalk.white(int.request.method || '???'),
49
+ chalk.gray(url.substring(0, 42) + (url.length > 42 ? '...' : '')),
49
50
  statusColor(int.response.status),
50
- chalk.gray(int.metadata.duration_ms + 'ms'),
51
+ chalk.gray((int.metadata.duration_ms || 0) + 'ms'),
51
52
  ]);
52
53
  });
53
54
 
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
+ }