kiroo 0.2.0 → 0.4.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.
package/bin/kiroo.js CHANGED
@@ -6,16 +6,18 @@ import { executeRequest } from '../src/executor.js';
6
6
  import { listInteractions, replayInteraction } from '../src/replay.js';
7
7
  import { saveSnapshot, compareSnapshots, listSnapshots } from '../src/snapshot.js';
8
8
  import { setEnv, setVar, deleteVar, listEnv } from '../src/env.js';
9
- // import { showGraph } from '../src/graph.js';
9
+ import { showGraph } from '../src/graph.js';
10
10
  import { initProject } from '../src/init.js';
11
- // import { showStats } from '../src/stats.js';
11
+ import { showStats } from '../src/stats.js';
12
+ import { handleImport } from '../src/import.js';
13
+ import { clearAllInteractions } from '../src/storage.js';
12
14
 
13
15
  const program = new Command();
14
16
 
15
17
  program
16
18
  .name('kiroo')
17
19
  .description('Git for API interactions. Record, replay, snapshot, and diff your APIs.')
18
- .version('0.2.0');
20
+ .version('0.4.0');
19
21
 
20
22
  // Init command
21
23
  program
@@ -58,6 +60,28 @@ program
58
60
  await replayInteraction(id);
59
61
  });
60
62
 
63
+ // Clear command
64
+ program
65
+ .command('clear')
66
+ .description('Clear all stored interaction history')
67
+ .option('-f, --force', 'Force clear without confirmation')
68
+ .action(async (options) => {
69
+ if (!options.force) {
70
+ const inquirer = (await import('inquirer')).default;
71
+ const { confirm } = await inquirer.prompt([
72
+ {
73
+ type: 'confirm',
74
+ name: 'confirm',
75
+ message: chalk.red('Are you sure you want to clear all history?'),
76
+ default: false
77
+ }
78
+ ]);
79
+ if (!confirm) return;
80
+ }
81
+ clearAllInteractions();
82
+ console.log(chalk.green('\n ✨ History cleared successfully.\n'));
83
+ });
84
+
61
85
  // Environment commands
62
86
  const env = program.command('env').description('Environment management');
63
87
 
@@ -105,6 +129,37 @@ snapshot
105
129
  await compareSnapshots(tag1, tag2);
106
130
  });
107
131
 
132
+ // Graph command
133
+ program
134
+ .command('graph')
135
+ .description('Show visual dependency graph of API interactions')
136
+ .action(async () => {
137
+ await showGraph();
138
+ });
139
+
140
+ // Import command
141
+ program
142
+ .command('import')
143
+ .description('Import a request from a cURL command (opens editor if no command is provided)')
144
+ .allowUnknownOption()
145
+ .action(async (_, command) => {
146
+ if (command.args.length > 0) {
147
+ // Pass tokens directly to handleImport
148
+ await handleImport(command.args);
149
+ } else {
150
+ const inquirer = (await import('inquirer')).default;
151
+ const response = await inquirer.prompt([
152
+ {
153
+ type: 'editor',
154
+ name: 'curl',
155
+ message: 'Paste your cURL command here (opens your default editor):',
156
+ validate: (input) => input.trim().length > 0 || 'Please enter a cURL command'
157
+ }
158
+ ]);
159
+ await handleImport(response.curl);
160
+ }
161
+ });
162
+
108
163
  // Graph command
109
164
  /*
110
165
  program
@@ -116,14 +171,12 @@ program
116
171
  */
117
172
 
118
173
  // Stats command
119
- /*
120
174
  program
121
175
  .command('stats')
122
176
  .description('Show usage statistics')
123
177
  .action(async () => {
124
178
  await showStats();
125
179
  });
126
- */
127
180
 
128
181
  // Error handling
129
182
  program.exitOverride();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiroo",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Git for API interactions. Record, replay, snapshot, and diff your APIs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/executor.js CHANGED
@@ -4,16 +4,20 @@ import ora from 'ora';
4
4
  import { saveInteraction, loadEnv, saveEnv } from './storage.js';
5
5
  import { formatResponse } from './formatter.js';
6
6
 
7
- function applyEnvReplacements(data, envVars) {
7
+ function applyEnvReplacements(data, envVars, usedKeys = null) {
8
8
  if (typeof data === 'string') {
9
9
  return data.replace(/\{\{(.+?)\}\}/g, (match, key) => {
10
- return envVars[key] !== undefined ? envVars[key] : match;
10
+ if (envVars[key] !== undefined) {
11
+ if (usedKeys) usedKeys.add(key);
12
+ return envVars[key];
13
+ }
14
+ return match;
11
15
  });
12
16
  }
13
17
  if (typeof data === 'object' && data !== null) {
14
18
  const newData = Array.isArray(data) ? [] : {};
15
19
  for (const key in data) {
16
- newData[key] = applyEnvReplacements(data[key], envVars);
20
+ newData[key] = applyEnvReplacements(data[key], envVars, usedKeys);
17
21
  }
18
22
  return newData;
19
23
  }
@@ -52,8 +56,53 @@ export async function executeRequest(method, url, options = {}) {
52
56
  const env = loadEnv();
53
57
  const currentEnvVars = env.environments[env.current] || {};
54
58
 
59
+ const usedKeys = new Set();
60
+ const savedKeys = [];
61
+
55
62
  // Apply replacements to URL
56
- url = applyEnvReplacements(url, currentEnvVars);
63
+ url = applyEnvReplacements(url, currentEnvVars, usedKeys);
64
+
65
+ // Auto-BaseURL logic
66
+ if (currentEnvVars.baseUrl) {
67
+ let isRelative = false;
68
+ let pathPart = url;
69
+
70
+ // 1. Direct relative path
71
+ if (url.startsWith('/')) {
72
+ isRelative = true;
73
+ }
74
+ // 2. Windows Git Bash conversion: Detect "C:/..." style paths with no protocol
75
+ else if (process.platform === 'win32' && /^[a-zA-Z]:[/\\]/.test(url) && !url.includes('://')) {
76
+ isRelative = true;
77
+ // Extract the part after the drive letter and potential Git Bash root
78
+ // We look for common markers or just the first segment that looks like a path
79
+ // Most reliable for Git Bash: The user's path is at the end.
80
+ // We'll try to find the /api, /v1, etc., or just fallback to the full path after the first few segments
81
+ const segments = url.split(/[/\\]/);
82
+ const apiIdx = segments.findIndex(s => s === 'api' || s === 'v1' || s === 'v2');
83
+ if (apiIdx !== -1) {
84
+ pathPart = '/' + segments.slice(apiIdx).join('/');
85
+ } else {
86
+ // Fallback: If we can't find a marker, it's hard to guess safely,
87
+ // but we can try to strip the drive letter and first few segments
88
+ // In Git Bash, it's usually C:/Program Files/Git/api...
89
+ // Let's at least strip the drive letter root
90
+ pathPart = url.replace(/^[a-zA-Z]:/, '').replace(/\\/g, '/');
91
+ if (!pathPart.startsWith('/')) pathPart = '/' + pathPart;
92
+ }
93
+ }
94
+ // 3. No leading slash but doesn't look like a host (no dots, no protocol)
95
+ else if (!url.includes('://') && !url.includes('.') && !url.includes(':') && !url.startsWith('localhost')) {
96
+ isRelative = true;
97
+ pathPart = '/' + url;
98
+ }
99
+
100
+ if (isRelative) {
101
+ const baseUrl = currentEnvVars.baseUrl;
102
+ const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
103
+ url = normalizedBaseUrl + (pathPart.startsWith('/') ? pathPart : '/' + pathPart);
104
+ }
105
+ }
57
106
 
58
107
  // Parse headers
59
108
  const headers = {};
@@ -61,7 +110,7 @@ export async function executeRequest(method, url, options = {}) {
61
110
  options.header.forEach(h => {
62
111
  const [key, ...valueParts] = h.split(':');
63
112
  const headerValue = valueParts.join(':').trim();
64
- headers[key.trim()] = applyEnvReplacements(headerValue, currentEnvVars);
113
+ headers[key.trim()] = applyEnvReplacements(headerValue, currentEnvVars, usedKeys);
65
114
  });
66
115
  }
67
116
 
@@ -70,7 +119,7 @@ export async function executeRequest(method, url, options = {}) {
70
119
  if (options.data) {
71
120
  let rawData = options.data;
72
121
  // Apply replacements to raw data string before parsing
73
- rawData = applyEnvReplacements(rawData, currentEnvVars);
122
+ rawData = applyEnvReplacements(rawData, currentEnvVars, usedKeys);
74
123
 
75
124
  try {
76
125
  body = JSON.parse(rawData);
@@ -133,6 +182,7 @@ export async function executeRequest(method, url, options = {}) {
133
182
  const val = getDeep(response, responsePath);
134
183
  if (val !== undefined) {
135
184
  env.environments[env.current][envKey] = val;
185
+ savedKeys.push(envKey);
136
186
  console.log(chalk.cyan(` ✨ Saved to env:`), chalk.white(`${envKey}=${val}`));
137
187
  } else {
138
188
  console.log(chalk.yellow(` ⚠️ Could not find path '${responsePath}' in response`));
@@ -155,6 +205,8 @@ export async function executeRequest(method, url, options = {}) {
155
205
  data: response.data,
156
206
  },
157
207
  duration,
208
+ saves: savedKeys,
209
+ uses: Array.from(usedKeys)
158
210
  });
159
211
 
160
212
  console.log(chalk.gray('\n 💾 Interaction saved:'), chalk.white(interactionId));
package/src/graph.js ADDED
@@ -0,0 +1,75 @@
1
+ import chalk from 'chalk';
2
+ import { getAllInteractions } from './storage.js';
3
+
4
+ export async function showGraph() {
5
+ const interactions = getAllInteractions().reverse(); // Chronological order
6
+
7
+ if (interactions.length === 0) {
8
+ console.log(chalk.yellow('\n No interactions recorded yet.'));
9
+ console.log(chalk.gray(' Run some requests to see the dependency graph!\n'));
10
+ return;
11
+ }
12
+
13
+ // 1. Map: Variable -> Provider Interaction (Method + Path)
14
+ const variableProviders = {};
15
+
16
+ // 2. Interaction nodes with their connections
17
+ const nodes = [];
18
+
19
+ const getPath = (urlStr) => {
20
+ try {
21
+ const urlObj = new URL(urlStr);
22
+ return urlObj.pathname;
23
+ } catch (e) {
24
+ return urlStr.startsWith('http') ? urlStr : (urlStr.startsWith('/') ? urlStr : '/' + urlStr);
25
+ }
26
+ };
27
+
28
+ interactions.forEach(int => {
29
+ const path = getPath(int.request.url);
30
+ const method = int.request.method;
31
+ const saves = int.metadata.saves || [];
32
+ const uses = int.metadata.uses || [];
33
+
34
+ // Track which variables this interaction provides
35
+ saves.forEach(v => {
36
+ variableProviders[v] = { method, path };
37
+ });
38
+
39
+ nodes.push({ method, path, saves, uses });
40
+ });
41
+
42
+ console.log(chalk.cyan('\n 🕸️ API Dependency Graph:'));
43
+ console.log(chalk.gray(' (Shows how data flows between endpoints)\n'));
44
+
45
+ // Simple visualization
46
+ const seenPaths = new Set();
47
+
48
+ nodes.forEach((node, idx) => {
49
+ const nodeLabel = `${chalk.white(node.method)} ${chalk.gray(node.path)}`;
50
+
51
+ // Find dependencies based on 'uses'
52
+ const dependencies = node.uses.map(v => {
53
+ const provider = variableProviders[v];
54
+ return provider ? `[${v}] from ${provider.method} ${provider.path}` : null;
55
+ }).filter(Boolean);
56
+
57
+ // Render the node
58
+ if (dependencies.length > 0) {
59
+ console.log(` ${chalk.blue('⬇')} ${nodeLabel}`);
60
+ dependencies.forEach((dep, depIdx) => {
61
+ const branchToken = depIdx === dependencies.length - 1 ? '└─' : '├─';
62
+ console.log(` ${chalk.gray(branchToken)} ${chalk.yellow('uses')} ${dep}`);
63
+ });
64
+ } else {
65
+ console.log(` ${chalk.green('○')} ${nodeLabel}`);
66
+ }
67
+
68
+ if (node.saves.length > 0) {
69
+ const saveToken = node.uses.length > 0 ? ' │' : ' ';
70
+ console.log(`${saveToken} ${chalk.magenta('↳')} ${chalk.gray('saves')} ${chalk.white(node.saves.join(', '))}`);
71
+ }
72
+
73
+ console.log('');
74
+ });
75
+ }
package/src/import.js ADDED
@@ -0,0 +1,113 @@
1
+ import chalk from 'chalk';
2
+ import { executeRequest } from './executor.js';
3
+
4
+ export async function handleImport(input) {
5
+ try {
6
+ let tokens = [];
7
+ if (Array.isArray(input)) {
8
+ tokens = input;
9
+ } else {
10
+ tokens = stringToTokens(input);
11
+ }
12
+
13
+ const parsed = parseCurlTokens(tokens);
14
+
15
+ console.log(chalk.cyan('\n 📥 Parsed cURL Command:'));
16
+ console.log(chalk.gray(` Method: `), chalk.white(parsed.method));
17
+ console.log(chalk.gray(` URL: `), chalk.white(parsed.url));
18
+ console.log(chalk.gray(` Headers:`), chalk.white(Object.keys(parsed.headers).length));
19
+ if (parsed.body) {
20
+ console.log(chalk.gray(` Body: `), chalk.white('Present'));
21
+ }
22
+ console.log('');
23
+
24
+ // Convert parsed object to options format for executeRequest
25
+ const options = {
26
+ header: Object.entries(parsed.headers).map(([k, v]) => `${k}: ${v}`),
27
+ data: parsed.body
28
+ };
29
+
30
+ await executeRequest(parsed.method, parsed.url, options);
31
+
32
+ } catch (error) {
33
+ console.error(chalk.red('\n ✗ Import failed:'), error.message, '\n');
34
+ }
35
+ }
36
+
37
+ function stringToTokens(curlString) {
38
+ // Clean up shell artifacts (Windows ^, backslashes for line continuation)
39
+ let cleanStr = curlString.replace(/\^/g, '').replace(/\\\n/g, ' ').trim();
40
+
41
+ const tokens = [];
42
+ let currentToken = '';
43
+ let inQuotes = false;
44
+ let quoteChar = '';
45
+
46
+ for (let i = 0; i < cleanStr.length; i++) {
47
+ const char = cleanStr[i];
48
+ if ((char === "'" || char === '"') && (i === 0 || cleanStr[i - 1] !== '\\')) {
49
+ if (inQuotes && char === quoteChar) {
50
+ inQuotes = false;
51
+ tokens.push(currentToken);
52
+ currentToken = '';
53
+ quoteChar = '';
54
+ } else if (!inQuotes) {
55
+ inQuotes = true;
56
+ quoteChar = char;
57
+ } else {
58
+ currentToken += char;
59
+ }
60
+ } else if (char === ' ' && !inQuotes) {
61
+ if (currentToken) {
62
+ tokens.push(currentToken);
63
+ currentToken = '';
64
+ }
65
+ } else {
66
+ currentToken += char;
67
+ }
68
+ }
69
+ if (currentToken) tokens.push(currentToken);
70
+ return tokens;
71
+ }
72
+
73
+ function parseCurlTokens(tokens) {
74
+ const result = {
75
+ method: 'GET',
76
+ url: '',
77
+ headers: {},
78
+ body: ''
79
+ };
80
+
81
+ for (let i = 0; i < tokens.length; i++) {
82
+ const token = tokens[i].trim();
83
+ if (!token) continue;
84
+
85
+ if (token === '-X' || token === '--request') {
86
+ result.method = tokens[++i].toUpperCase();
87
+ } else if (token === '-H' || token === '--header') {
88
+ const headerStr = tokens[++i];
89
+ if (!headerStr) continue;
90
+ const colonIdx = headerStr.indexOf(':');
91
+ if (colonIdx !== -1) {
92
+ const key = headerStr.substring(0, colonIdx).trim();
93
+ const value = headerStr.substring(colonIdx + 1).trim();
94
+ result.headers[key] = value;
95
+ }
96
+ } else if (token === '-d' || token === '--data' || token === '--data-raw' || token === '--data-binary') {
97
+ result.body = tokens[++i];
98
+ if (result.method === 'GET') result.method = 'POST';
99
+ } else if (token.includes('://') || (token.startsWith('localhost') || token.startsWith('127.0.0.1'))) {
100
+ if (!result.url) result.url = token;
101
+ } else if (token === 'curl' || token.startsWith('-')) {
102
+ continue;
103
+ } else {
104
+ if (!result.url) result.url = token;
105
+ }
106
+ }
107
+
108
+ if (result.url) {
109
+ result.url = result.url.replace(/^["']|["']$/g, '');
110
+ }
111
+
112
+ return result;
113
+ }
package/src/snapshot.js CHANGED
@@ -59,15 +59,27 @@ export async function compareSnapshots(tag1, tag2) {
59
59
  const results = [];
60
60
  let breakingChanges = 0;
61
61
 
62
- // Simplistic comparison: match by URL and Method
62
+ // Helper to get path from URL string
63
+ const getPath = (urlStr) => {
64
+ try {
65
+ const urlObj = new URL(urlStr);
66
+ return urlObj.pathname;
67
+ } catch (e) {
68
+ // If it's already a path or invalid full URL, return as is
69
+ return urlStr.startsWith('http') ? urlStr : (urlStr.startsWith('/') ? urlStr : '/' + urlStr);
70
+ }
71
+ };
72
+
73
+ // Domain-agnostic comparison: match by Path and Method
63
74
  s2.interactions.forEach(int2 => {
64
- const int1 = s1.interactions.find(i => i.url === int2.url && i.method === int2.method);
75
+ const path2 = getPath(int2.url);
76
+ const int1 = s1.interactions.find(i => getPath(i.url) === path2 && i.method === int2.method);
65
77
 
66
78
  if (!int1) {
67
79
  results.push({
68
80
  type: 'NEW',
69
81
  method: int2.method,
70
- url: int2.url,
82
+ url: path2,
71
83
  msg: chalk.blue('New interaction added')
72
84
  });
73
85
  return;
@@ -97,7 +109,7 @@ export async function compareSnapshots(tag1, tag2) {
97
109
  results.push({
98
110
  type: 'CHANGE',
99
111
  method: int2.method,
100
- url: int2.url,
112
+ url: path2,
101
113
  msg: diffs.join('\n ')
102
114
  });
103
115
  }
package/src/stats.js ADDED
@@ -0,0 +1,73 @@
1
+ import chalk from 'chalk';
2
+ import Table from 'cli-table3';
3
+ import { getAllInteractions } from './storage.js';
4
+
5
+ export function showStats() {
6
+ const interactions = getAllInteractions();
7
+
8
+ if (interactions.length === 0) {
9
+ console.log(chalk.yellow('\n No interactions found to analyze.'));
10
+ console.log(chalk.gray(' Run some requests first to see the magic! ✨\n'));
11
+ return;
12
+ }
13
+
14
+ console.log(chalk.cyan.bold('\n 📊 Kiroo Analytics Dashboard\n'));
15
+
16
+ // 1. General Metrics
17
+ const total = interactions.length;
18
+ const successes = interactions.filter(i => i.response.status >= 200 && i.response.status < 300).length;
19
+ const successRate = ((successes / total) * 100).toFixed(1);
20
+ const avgDuration = (interactions.reduce((acc, i) => acc + i.metadata.duration_ms, 0) / total).toFixed(0);
21
+
22
+ const generalTable = new Table();
23
+ generalTable.push(
24
+ { [chalk.white('Total Requests')]: chalk.cyan(total) },
25
+ { [chalk.white('Success Rate')]: successRate >= 80 ? chalk.green(successRate + '%') : chalk.yellow(successRate + '%') },
26
+ { [chalk.white('Avg. Duration')]: chalk.white(avgDuration + 'ms') }
27
+ );
28
+
29
+ console.log(chalk.white.bold(' ● General Performance'));
30
+ console.log(generalTable.toString());
31
+
32
+ // 2. Method Distribution
33
+ const methods = {};
34
+ interactions.forEach(i => {
35
+ methods[i.request.method] = (methods[i.request.method] || 0) + 1;
36
+ });
37
+
38
+ const methodTable = new Table({
39
+ head: ['Method', 'Count', 'Percentage'].map(h => chalk.cyan(h)),
40
+ colWidths: [15, 10, 15]
41
+ });
42
+
43
+ Object.entries(methods).forEach(([method, count]) => {
44
+ const percentage = ((count / total) * 100).toFixed(1) + '%';
45
+ methodTable.push([chalk.white(method), chalk.white(count), chalk.gray(percentage)]);
46
+ });
47
+
48
+ console.log(chalk.white.bold('\n ● Request Distribution'));
49
+ console.log(methodTable.toString());
50
+
51
+ // 3. Slowest Endpoints
52
+ const slowTable = new Table({
53
+ head: ['URL', 'Status', 'Duration'].map(h => chalk.cyan(h)),
54
+ colWidths: [45, 10, 15]
55
+ });
56
+
57
+ const sortedBySlowest = [...interactions].sort((a, b) => b.metadata.duration_ms - a.metadata.duration_ms);
58
+ const topSlow = sortedBySlowest.slice(0, 5);
59
+
60
+ topSlow.forEach(i => {
61
+ const url = i.request.url.length > 42 ? i.request.url.substring(0, 39) + '...' : i.request.url;
62
+ const statusColor = i.response.status < 400 ? chalk.green : chalk.red;
63
+ slowTable.push([
64
+ chalk.gray(url),
65
+ statusColor(i.response.status),
66
+ chalk.red(i.metadata.duration_ms + 'ms')
67
+ ]);
68
+ });
69
+
70
+ console.log(chalk.white.bold('\n ● Top 5 Slowest Endpoints (Bottlenecks)'));
71
+ console.log(slowTable.toString());
72
+ console.log('');
73
+ }
package/src/storage.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs';
1
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'fs';
2
2
  import { join } from 'path';
3
3
 
4
4
  const KIROO_DIR = '.kiroo';
@@ -39,6 +39,8 @@ export async function saveInteraction(interaction) {
39
39
  response: interaction.response,
40
40
  metadata: {
41
41
  duration_ms: interaction.duration,
42
+ saves: interaction.saves || [], // Variables saved from this response
43
+ uses: interaction.uses || [], // Variables used in this request
42
44
  },
43
45
  };
44
46
 
@@ -112,6 +114,17 @@ export function getAllSnapshots() {
112
114
  .map(f => f.replace('.json', ''));
113
115
  }
114
116
 
117
+ export function clearAllInteractions() {
118
+ ensureKirooDir();
119
+ if (existsSync(INTERACTIONS_DIR)) {
120
+ const files = readdirSync(INTERACTIONS_DIR);
121
+ files.forEach(f => {
122
+ const filepath = join(INTERACTIONS_DIR, f);
123
+ rmSync(filepath, { force: true });
124
+ });
125
+ }
126
+ }
127
+
115
128
  export function loadEnv() {
116
129
  ensureKirooDir();
117
130
  const data = readFileSync(ENV_FILE, 'utf8');